1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-22 12:15:36 +01:00

Added the ability to create a JWT on an organization license that contains all license properties as claims

This commit is contained in:
Conner Turnbull 2024-10-28 16:02:19 -04:00
parent b07df10335
commit 02fd8b0f3e
No known key found for this signature in database
GPG Key ID: D42CA06D8EB866CC
14 changed files with 268 additions and 5 deletions

View File

@ -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<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
services.AddLicenseServices();
}
}

View File

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

View File

@ -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<ILicenseClaimsFactory<Organization>, OrganizationLicenseClaimsFactory>();
}
}

View File

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

View File

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

View File

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

View File

@ -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<string> GetAllKeys()
{

View File

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

View File

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

View File

@ -14,4 +14,8 @@ public interface ILicensingService
Task<OrganizationLicense> ReadOrganizationLicenseAsync(Organization organization);
Task<OrganizationLicense> ReadOrganizationLicenseAsync(Guid organizationId);
Task<string> CreateOrganizationTokenAsync(
Organization organization,
Guid installationId,
SubscriptionInfo subscriptionInfo);
}

View File

@ -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<LicensingService> _logger;
private readonly ILicenseClaimsFactory<Organization> _organizationLicenseClaimsFactory;
private readonly IFeatureService _featureService;
private IDictionary<Guid, DateTime> _userCheckCache = new Dictionary<Guid, DateTime>();
@ -32,7 +41,9 @@ public class LicensingService : ILicensingService
IMailService mailService,
IWebHostEnvironment environment,
ILogger<LicensingService> logger,
IGlobalSettings globalSettings)
IGlobalSettings globalSettings,
ILicenseClaimsFactory<Organization> 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<OrganizationLicense>(fs);
}
public async Task<string> 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<Claim> 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);
}
}

View File

@ -53,4 +53,9 @@ public class NoopLicensingService : ILicensingService
{
return Task.FromResult<OrganizationLicense>(null);
}
public Task<string> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo)
{
return Task.FromResult<string>(null);
}
}

View File

@ -226,7 +226,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IPaymentHistoryService, PaymentHistoryService>();
services.AddSingleton<IStripeSyncService, StripeSyncService>();
services.AddSingleton<IMailService, HandlebarsMailService>();
services.AddSingleton<ILicensingService, LicensingService>();
services.AddScoped<ILicensingService, LicensingService>();
services.AddSingleton<ILookupClient>(_ =>
{
var options = new LookupClientOptions { Timeout = TimeSpan.FromSeconds(15), UseTcpOnly = true };

View File

@ -80,7 +80,7 @@ public class UpdateOrganizationLicenseCommandTests
.ReplaceAndUpdateCacheAsync(Arg.Is<Organization>(
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));
}