Skip to content

Commit

Permalink
[PM-11516] Initial license file refactor (#5002)
Browse files Browse the repository at this point in the history
* Added the ability to create a JWT on an organization license that contains all license properties as claims

* Added the ability to create a JWT on a user license that contains all license properties as claims

* Added ability to consume JWT licenses

* Resolved generic type issues when getting claim value

* Now validating the jwt signature, exp, and iat

* Moved creation of ClaimsPrincipal outside of licenses given dependecy on cert

* Ran dotnet format. Resolved identity error

* Updated claim types to use string constants

* Updated jwt expires to be one year

* Fixed bug requiring email verification to be on the token

* dotnet format

* Patch build process

---------

Co-authored-by: Matt Bishop <[email protected]>
  • Loading branch information
cturnbull-bitwarden and withinfocus authored Dec 5, 2024
1 parent 0e32dcc commit 04cf513
Show file tree
Hide file tree
Showing 23 changed files with 847 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,8 @@ private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey,
string privateKey)
{
var canUse = license.CanUse(_globalSettings, _licensingService, out var exception);
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception);
if (!canUse)
{
throw new BadRequestException(exception);
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,5 +16,6 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
services.AddLicenseServices();
}
}
151 changes: 151 additions & 0 deletions src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using System.Security.Claims;
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;
}

public static T GetValue<T>(this ClaimsPrincipal principal, string claimType)
{
var claim = principal.FindFirst(claimType);

if (claim is null)
{
return default;
}

// Handle Guid
if (typeof(T) == typeof(Guid))
{
return Guid.TryParse(claim.Value, out var guid)
? (T)(object)guid
: default;
}

// Handle DateTime
if (typeof(T) == typeof(DateTime))
{
return DateTime.TryParse(claim.Value, out var dateTime)
? (T)(object)dateTime
: default;
}

// Handle TimeSpan
if (typeof(T) == typeof(TimeSpan))
{
return TimeSpan.TryParse(claim.Value, out var timeSpan)
? (T)(object)timeSpan
: default;
}

// Check for Nullable Types
var underlyingType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);

// Handle Enums
if (underlyingType.IsEnum)
{
if (Enum.TryParse(underlyingType, claim.Value, true, out var enumValue))
{
return (T)enumValue; // Cast back to T
}

return default; // Return default value for non-nullable enums or null for nullable enums
}

// Handle other Nullable Types (e.g., int?, bool?)
if (underlyingType == typeof(int))
{
return int.TryParse(claim.Value, out var intValue)
? (T)(object)intValue
: default;
}

if (underlyingType == typeof(bool))
{
return bool.TryParse(claim.Value, out var boolValue)
? (T)(object)boolValue
: default;
}

if (underlyingType == typeof(double))
{
return double.TryParse(claim.Value, out var doubleValue)
? (T)(object)doubleValue
: default;
}

// Fallback to Convert.ChangeType for other types including strings
return (T)Convert.ChangeType(claim.Value, underlyingType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Licenses.Services;
using Bit.Core.Billing.Licenses.Services.Implementations;
using Bit.Core.Entities;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Billing.Licenses.Extensions;

public static class LicenseServiceCollectionExtensions
{
public static void AddLicenseServices(this IServiceCollection services)
{
services.AddTransient<ILicenseClaimsFactory<Organization>, OrganizationLicenseClaimsFactory>();
services.AddTransient<ILicenseClaimsFactory<User>, UserLicenseClaimsFactory>();
}
}
58 changes: 58 additions & 0 deletions src/Core/Billing/Licenses/LicenseConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace Bit.Core.Billing.Licenses;

public static class OrganizationLicenseConstants
{
public const string LicenseType = nameof(LicenseType);
public const string LicenseKey = nameof(LicenseKey);
public const string InstallationId = nameof(InstallationId);
public const string Id = nameof(Id);
public const string Name = nameof(Name);
public const string BusinessName = nameof(BusinessName);
public const string BillingEmail = nameof(BillingEmail);
public const string Enabled = nameof(Enabled);
public const string Plan = nameof(Plan);
public const string PlanType = nameof(PlanType);
public const string Seats = nameof(Seats);
public const string MaxCollections = nameof(MaxCollections);
public const string UsePolicies = nameof(UsePolicies);
public const string UseSso = nameof(UseSso);
public const string UseKeyConnector = nameof(UseKeyConnector);
public const string UseScim = nameof(UseScim);
public const string UseGroups = nameof(UseGroups);
public const string UseEvents = nameof(UseEvents);
public const string UseDirectory = nameof(UseDirectory);
public const string UseTotp = nameof(UseTotp);
public const string Use2fa = nameof(Use2fa);
public const string UseApi = nameof(UseApi);
public const string UseResetPassword = nameof(UseResetPassword);
public const string MaxStorageGb = nameof(MaxStorageGb);
public const string SelfHost = nameof(SelfHost);
public const string UsersGetPremium = nameof(UsersGetPremium);
public const string UseCustomPermissions = nameof(UseCustomPermissions);
public const string Issued = nameof(Issued);
public const string UsePasswordManager = nameof(UsePasswordManager);
public const string UseSecretsManager = nameof(UseSecretsManager);
public const string SmSeats = nameof(SmSeats);
public const string SmServiceAccounts = nameof(SmServiceAccounts);
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
public const string Expires = nameof(Expires);
public const string Refresh = nameof(Refresh);
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
public const string Trial = nameof(Trial);
}

public static class UserLicenseConstants
{
public const string LicenseType = nameof(LicenseType);
public const string LicenseKey = nameof(LicenseKey);
public const string Id = nameof(Id);
public const string Name = nameof(Name);
public const string Email = nameof(Email);
public const string Premium = nameof(Premium);
public const string MaxStorageGb = nameof(MaxStorageGb);
public const string Issued = nameof(Issued);
public const string Expires = nameof(Expires);
public const string Refresh = nameof(Refresh);
public const string Trial = nameof(Trial);
}
10 changes: 10 additions & 0 deletions src/Core/Billing/Licenses/Models/LicenseContext.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
9 changes: 9 additions & 0 deletions src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Security.Claims;
using Bit.Core.Billing.Licenses.Models;

namespace Bit.Core.Billing.Licenses.Services;

public interface ILicenseClaimsFactory<in T>
{
Task<List<Claim>> GenerateClaims(T entity, LicenseContext licenseContext);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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<Organization>
{
public Task<List<Claim>> 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<Claim>
{
new(nameof(OrganizationLicenseConstants.LicenseType), LicenseType.Organization.ToString()),
new Claim(nameof(OrganizationLicenseConstants.LicenseKey), entity.LicenseKey),
new(nameof(OrganizationLicenseConstants.InstallationId), licenseContext.InstallationId.ToString()),
new(nameof(OrganizationLicenseConstants.Id), entity.Id.ToString()),
new(nameof(OrganizationLicenseConstants.Name), entity.Name),
new(nameof(OrganizationLicenseConstants.BillingEmail), entity.BillingEmail),
new(nameof(OrganizationLicenseConstants.Enabled), entity.Enabled.ToString()),
new(nameof(OrganizationLicenseConstants.Plan), entity.Plan),
new(nameof(OrganizationLicenseConstants.PlanType), entity.PlanType.ToString()),
new(nameof(OrganizationLicenseConstants.Seats), entity.Seats.ToString()),
new(nameof(OrganizationLicenseConstants.MaxCollections), entity.MaxCollections.ToString()),
new(nameof(OrganizationLicenseConstants.UsePolicies), entity.UsePolicies.ToString()),
new(nameof(OrganizationLicenseConstants.UseSso), entity.UseSso.ToString()),
new(nameof(OrganizationLicenseConstants.UseKeyConnector), entity.UseKeyConnector.ToString()),
new(nameof(OrganizationLicenseConstants.UseScim), entity.UseScim.ToString()),
new(nameof(OrganizationLicenseConstants.UseGroups), entity.UseGroups.ToString()),
new(nameof(OrganizationLicenseConstants.UseEvents), entity.UseEvents.ToString()),
new(nameof(OrganizationLicenseConstants.UseDirectory), entity.UseDirectory.ToString()),
new(nameof(OrganizationLicenseConstants.UseTotp), entity.UseTotp.ToString()),
new(nameof(OrganizationLicenseConstants.Use2fa), entity.Use2fa.ToString()),
new(nameof(OrganizationLicenseConstants.UseApi), entity.UseApi.ToString()),
new(nameof(OrganizationLicenseConstants.UseResetPassword), entity.UseResetPassword.ToString()),
new(nameof(OrganizationLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()),
new(nameof(OrganizationLicenseConstants.SelfHost), entity.SelfHost.ToString()),
new(nameof(OrganizationLicenseConstants.UsersGetPremium), entity.UsersGetPremium.ToString()),
new(nameof(OrganizationLicenseConstants.UseCustomPermissions), entity.UseCustomPermissions.ToString()),
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.UsePasswordManager), entity.UsePasswordManager.ToString()),
new(nameof(OrganizationLicenseConstants.UseSecretsManager), entity.UseSecretsManager.ToString()),
new(nameof(OrganizationLicenseConstants.SmSeats), entity.SmSeats.ToString()),
new(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString()),
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion), entity.LimitCollectionCreationDeletion.ToString()),
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()),
};

if (entity.BusinessName is not null)
{
claims.Add(new Claim(nameof(OrganizationLicenseConstants.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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Globalization;
using System.Security.Claims;
using Bit.Core.Billing.Licenses.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;

namespace Bit.Core.Billing.Licenses.Services.Implementations;

public class UserLicenseClaimsFactory : ILicenseClaimsFactory<User>
{
public Task<List<Claim>> GenerateClaims(User entity, LicenseContext licenseContext)
{
var subscriptionInfo = licenseContext.SubscriptionInfo;

var expires = subscriptionInfo.UpcomingInvoice?.Date?.AddDays(7) ?? entity.PremiumExpirationDate?.AddDays(7);
var refresh = subscriptionInfo.UpcomingInvoice?.Date ?? entity.PremiumExpirationDate;
var trial = (subscriptionInfo.Subscription?.TrialEndDate.HasValue ?? false) &&
subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow;

var claims = new List<Claim>
{
new(nameof(UserLicenseConstants.LicenseType), LicenseType.User.ToString()),
new(nameof(UserLicenseConstants.LicenseKey), entity.LicenseKey),
new(nameof(UserLicenseConstants.Id), entity.Id.ToString()),
new(nameof(UserLicenseConstants.Name), entity.Name),
new(nameof(UserLicenseConstants.Email), entity.Email),
new(nameof(UserLicenseConstants.Premium), entity.Premium.ToString()),
new(nameof(UserLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()),
new(nameof(UserLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
new(nameof(UserLicenseConstants.Expires), expires.ToString()),
new(nameof(UserLicenseConstants.Refresh), refresh.ToString()),
new(nameof(UserLicenseConstants.Trial), trial.ToString()),
};

return Task.FromResult(claims);
}
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ public static class FeatureFlagKeys
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
public const string InlineMenuTotp = "inline-menu-totp";
public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor";

public static List<string> GetAllKeys()
{
Expand Down
Loading

0 comments on commit 04cf513

Please sign in to comment.