diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index abfceac73..78253f739 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -15,5 +16,6 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddLicenseServices(); } } diff --git a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs new file mode 100644 index 000000000..c6e7faf6f --- /dev/null +++ b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs @@ -0,0 +1,78 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Models.Business; + +namespace Bit.Core.Billing.Licenses.Extensions; + +public static class LicenseExtensions +{ + + public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo) + { + if (subscriptionInfo?.Subscription == null) + { + if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue) + { + return org.ExpirationDate.Value; + } + + return DateTime.UtcNow.AddDays(7); + } + + var subscription = subscriptionInfo.Subscription; + + if (subscription.TrialEndDate > DateTime.UtcNow) + { + return subscription.TrialEndDate.Value; + } + + if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) + { + return org.ExpirationDate.Value; + } + + if (subscription.PeriodEndDate.HasValue && subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscription.PeriodEndDate + .Value + .AddDays(Bit.Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); + } + + return org.ExpirationDate?.AddMonths(11) ?? DateTime.UtcNow.AddYears(1); + } + + public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) + { + if (subscriptionInfo?.Subscription == null || + subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow || + org.ExpirationDate < DateTime.UtcNow) + { + return expirationDate; + } + + return subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180) || + DateTime.UtcNow - expirationDate > TimeSpan.FromDays(30) + ? DateTime.UtcNow.AddDays(30) + : expirationDate; + } + + public static DateTime CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) + { + if (subscriptionInfo?.Subscription is null) + { + return expirationDate; + } + + var subscription = subscriptionInfo.Subscription; + + if (subscription.TrialEndDate <= DateTime.UtcNow && + org.ExpirationDate >= DateTime.UtcNow && + subscription.PeriodEndDate.HasValue && + subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscription.PeriodEndDate.Value; + } + + return expirationDate; + } +} diff --git a/src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs new file mode 100644 index 000000000..c67badae5 --- /dev/null +++ b/src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses.Services; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Licenses.Extensions; + +public static class LicenseServiceCollectionExtensions +{ + public static void AddLicenseServices(this IServiceCollection services) + { + services.AddTransient, OrganizationLicenseClaimsFactory>(); + } +} diff --git a/src/Core/Billing/Licenses/Models/LicenseContext.cs b/src/Core/Billing/Licenses/Models/LicenseContext.cs new file mode 100644 index 000000000..8dcc24e93 --- /dev/null +++ b/src/Core/Billing/Licenses/Models/LicenseContext.cs @@ -0,0 +1,10 @@ +#nullable enable +using Bit.Core.Models.Business; + +namespace Bit.Core.Billing.Licenses.Models; + +public class LicenseContext +{ + public Guid? InstallationId { get; init; } + public required SubscriptionInfo SubscriptionInfo { get; init; } +} diff --git a/src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs new file mode 100644 index 000000000..926ad0468 --- /dev/null +++ b/src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; +using Bit.Core.Billing.Licenses.Models; + +namespace Bit.Core.Billing.Licenses.Services; + +public interface ILicenseClaimsFactory +{ + Task> GenerateClaims(T entity, LicenseContext licenseContext); +} diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs new file mode 100644 index 000000000..8f6a87c2c --- /dev/null +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -0,0 +1,79 @@ +using System.Globalization; +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Enums; +using Bit.Core.Models.Business; + +namespace Bit.Core.Billing.Licenses.Services.Implementations; + +public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory +{ + public Task> GenerateClaims(Organization entity, LicenseContext licenseContext) + { + var subscriptionInfo = licenseContext.SubscriptionInfo; + var expires = entity.CalculateFreshExpirationDate(subscriptionInfo); + var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, expires); + var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo, expires); + var trial = IsTrialing(entity, subscriptionInfo); + + var claims = new List + { + new(nameof(OrganizationLicense.LicenseType), LicenseType.Organization.ToString()), + new(nameof(OrganizationLicense.InstallationId), licenseContext.InstallationId.ToString()), + new(nameof(OrganizationLicense.Id), entity.Id.ToString()), + new(nameof(OrganizationLicense.Name), entity.Name), + new(nameof(OrganizationLicense.BillingEmail), entity.BillingEmail), + new(nameof(OrganizationLicense.Enabled), entity.Enabled.ToString()), + new(nameof(OrganizationLicense.Plan), entity.Plan), + new(nameof(OrganizationLicense.PlanType), entity.PlanType.ToString()), + new(nameof(OrganizationLicense.Seats), entity.Seats.ToString()), + new(nameof(OrganizationLicense.MaxCollections), entity.MaxCollections.ToString()), + new(nameof(OrganizationLicense.UsePolicies), entity.UsePolicies.ToString()), + new(nameof(OrganizationLicense.UseSso), entity.UseSso.ToString()), + new(nameof(OrganizationLicense.UseKeyConnector), entity.UseKeyConnector.ToString()), + new(nameof(OrganizationLicense.UseScim), entity.UseScim.ToString()), + new(nameof(OrganizationLicense.UseGroups), entity.UseGroups.ToString()), + new(nameof(OrganizationLicense.UseEvents), entity.UseEvents.ToString()), + new(nameof(OrganizationLicense.UseDirectory), entity.UseDirectory.ToString()), + new(nameof(OrganizationLicense.UseTotp), entity.UseTotp.ToString()), + new(nameof(OrganizationLicense.Use2fa), entity.Use2fa.ToString()), + new(nameof(OrganizationLicense.UseApi), entity.UseApi.ToString()), + new(nameof(OrganizationLicense.UseResetPassword), entity.UseResetPassword.ToString()), + new(nameof(OrganizationLicense.MaxStorageGb), entity.MaxStorageGb.ToString()), + new(nameof(OrganizationLicense.SelfHost), entity.SelfHost.ToString()), + new(nameof(OrganizationLicense.UsersGetPremium), entity.UsersGetPremium.ToString()), + new(nameof(OrganizationLicense.UseCustomPermissions), entity.UseCustomPermissions.ToString()), + new(nameof(OrganizationLicense.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicense.UsePasswordManager), entity.UsePasswordManager.ToString()), + new(nameof(OrganizationLicense.UseSecretsManager), entity.UseSecretsManager.ToString()), + new(nameof(OrganizationLicense.SmSeats), entity.SmSeats.ToString()), + new(nameof(OrganizationLicense.SmServiceAccounts), entity.SmServiceAccounts.ToString()), + new(nameof(OrganizationLicense.LimitCollectionCreationDeletion), entity.LimitCollectionCreationDeletion.ToString()), + new(nameof(OrganizationLicense.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()), + new(nameof(OrganizationLicense.Expires), expires.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicense.Refresh), refresh.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicense.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicense.Trial), trial.ToString()), + }; + + if (entity.LicenseKey is not null) + { + claims.Add(new Claim(nameof(OrganizationLicense.LicenseKey), entity.LicenseKey)); + } + + if (entity.BusinessName is not null) + { + claims.Add(new Claim(nameof(OrganizationLicense.BusinessName), entity.BusinessName)); + } + + return Task.FromResult(claims); + } + + private static bool IsTrialing(Organization org, SubscriptionInfo subscriptionInfo) => + subscriptionInfo?.Subscription is null + ? org.PlanType != PlanType.Custom || !org.ExpirationDate.HasValue + : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0bc6393d3..dfa4f9379 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -151,6 +151,7 @@ public static class FeatureFlagKeys public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string NewDeviceVerification = "new-device-verification"; public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; + public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor"; public static List GetAllKeys() { diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index ea5127364..f7b959068 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -151,6 +151,7 @@ public class OrganizationLicense : ILicense public LicenseType? LicenseType { get; set; } public string Hash { get; set; } public string Signature { get; set; } + public string Token { get; set; } [JsonIgnore] public byte[] SignatureBytes => Convert.FromBase64String(Signature); /// @@ -176,6 +177,7 @@ public class OrganizationLicense : ILicense !p.Name.Equals(nameof(Signature)) && !p.Name.Equals(nameof(SignatureBytes)) && !p.Name.Equals(nameof(LicenseType)) && + !p.Name.Equals(nameof(Token)) && // UsersGetPremium was added in Version 2 (Version >= 2 || !p.Name.Equals(nameof(UsersGetPremium))) && // UseEvents was added in Version 3 diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs index b8fad451e..a4b08736c 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs @@ -33,6 +33,10 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer } var subscriptionInfo = await _paymentService.GetSubscriptionAsync(organization); - return new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version); + + return new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version) + { + Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo) + }; } } diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Services/ILicensingService.cs index e92fa87fd..ed478c467 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Services/ILicensingService.cs @@ -14,4 +14,8 @@ public interface ILicensingService Task ReadOrganizationLicenseAsync(Organization organization); Task ReadOrganizationLicenseAsync(Guid organizationId); + Task CreateOrganizationTokenAsync( + Organization organization, + Guid installationId, + SubscriptionInfo subscriptionInfo); } diff --git a/src/Core/Services/Implementations/LicensingService.cs b/src/Core/Services/Implementations/LicensingService.cs index 85b8f3120..4128d72db 100644 --- a/src/Core/Services/Implementations/LicensingService.cs +++ b/src/Core/Services/Implementations/LicensingService.cs @@ -1,15 +1,22 @@ -using System.Security.Cryptography.X509Certificates; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; +using IdentityModel; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; namespace Bit.Core.Services; @@ -22,6 +29,8 @@ public class LicensingService : ILicensingService private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IMailService _mailService; private readonly ILogger _logger; + private readonly ILicenseClaimsFactory _organizationLicenseClaimsFactory; + private readonly IFeatureService _featureService; private IDictionary _userCheckCache = new Dictionary(); @@ -32,7 +41,9 @@ public class LicensingService : ILicensingService IMailService mailService, IWebHostEnvironment environment, ILogger logger, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + ILicenseClaimsFactory organizationLicenseClaimsFactory, + IFeatureService featureService) { _userRepository = userRepository; _organizationRepository = organizationRepository; @@ -40,6 +51,8 @@ public class LicensingService : ILicensingService _mailService = mailService; _logger = logger; _globalSettings = globalSettings; + _organizationLicenseClaimsFactory = organizationLicenseClaimsFactory; + _featureService = featureService; var certThumbprint = environment.IsDevelopment() ? "207E64A231E8AA32AAF68A61037C075EBEBD553F" : @@ -272,4 +285,46 @@ public class LicensingService : ILicensingService using var fs = File.OpenRead(filePath); return await JsonSerializer.DeserializeAsync(fs); } + + public async Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor)) + { + return null; + } + + var licenseContext = new LicenseContext + { + InstallationId = installationId, + SubscriptionInfo = subscriptionInfo, + }; + + var claims = await _organizationLicenseClaimsFactory.GenerateClaims(organization, licenseContext); + var audience = organization.Id.ToString(); + var expires = organization.CalculateFreshExpirationDate(subscriptionInfo); + return GenerateToken(claims, audience, expires); + } + + private string GenerateToken(List claims, string audience, DateTime expires) + { + if (claims.All(claim => claim.Type != JwtClaimTypes.JwtId)) + { + claims.Add(new Claim(JwtClaimTypes.JwtId, Guid.NewGuid().ToString())); + } + + var securityKey = new RsaSecurityKey(_certificate.GetRSAPrivateKey()); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = "bitwarden", + Audience = audience, + NotBefore = DateTime.UtcNow, + Expires = expires, + SigningCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature) + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } } diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Services/NoopImplementations/NoopLicensingService.cs index 8eb42a318..16470e2d5 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Services/NoopImplementations/NoopLicensingService.cs @@ -53,4 +53,9 @@ public class NoopLicensingService : ILicensingService { return Task.FromResult(null); } + + public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + { + return Task.FromResult(null); + } } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 5a5585952..931d643df 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -226,7 +226,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); services.AddSingleton(_ => { var options = new LookupClientOptions { Timeout = TimeSpan.FromSeconds(15), UseTcpOnly = true }; diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs index 565f2f32c..0a6c98a2a 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs @@ -80,7 +80,7 @@ public class UpdateOrganizationLicenseCommandTests .ReplaceAndUpdateCacheAsync(Arg.Is( org => AssertPropertyEqual(license, org, "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", - "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", "ExpirationWithoutGracePeriod") && + "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", "ExpirationWithoutGracePeriod", "Token") && // Same property but different name, use explicit mapping org.ExpirationDate == license.Expires)); }