[PM-28508] Fix No validation occurs for Expiration date on Self Host licenses (#6655)

* Fix the license validation bug

* resolve the failing test

* fix the failing test

* Revert changes and Add the ui display fix

* remove empty spaces

* revert the changes on licensing file

* revert changes to the test signup

* Revert the org license file changes

* revert the empty spaces

* revert the empty spaces changes

* remove the empty spaces

* revert

* Remove the duplicate code

* Add the expire date fix for premium

* Fix the failing test

* Fix the lint error
This commit is contained in:
cyprain-okeke 2025-12-04 16:28:01 +01:00 committed by GitHub
parent 655054aa56
commit d619a49998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 84 additions and 6 deletions

View File

@ -1,10 +1,13 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
#nullable disable #nullable disable
using System.Security.Claims;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -177,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
} }
} }
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) :
this(organization, (Plan)null)
{
if (license != null)
{
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
// The token's expiration is cryptographically secured and cannot be tampered with
// The file's Expires property can be manually edited and should NOT be trusted for display
if (claimsPrincipal != null)
{
Expiration = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Expires);
ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
}
else
{
// No token - use the license file expiration (for older licenses without tokens)
Expiration = license.Expires;
ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial
? license.Expires
: license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays));
}
}
}
public string StorageName { get; set; } public string StorageName { get; set; }
public double? StorageGb { get; set; } public double? StorageGb { get; set; }
public BillingCustomerDiscount CustomerDiscount { get; set; } public BillingCustomerDiscount CustomerDiscount { get; set; }

View File

@ -26,7 +26,8 @@ public class AccountsController(
IUserService userService, IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery, IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller IFeatureService featureService,
ILicensingService licensingService) : Controller
{ {
[HttpPost("premium")] [HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync( public async Task<PaymentResponseModel> PostPremiumAsync(
@ -97,12 +98,14 @@ public class AccountsController(
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount);
} }
else else
{ {
var license = await userService.GenerateLicenseAsync(user); var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license); var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, null, license, claimsPrincipal);
} }
} }
else else

View File

@ -67,7 +67,8 @@ public class OrganizationsController(
if (globalSettings.SelfHosted) if (globalSettings.SelfHosted)
{ {
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense); var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense);
return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal);
} }
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);

View File

@ -1,4 +1,7 @@
using Bit.Core.Billing.Constants; using System.Security.Claims;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel
: null; : null;
} }
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="claimsPrincipal">The claims principal containing cryptographically secure token claims</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
UpcomingInvoice = subscription?.UpcomingInvoice != null ?
new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB
MaxStorageGb = user.MaxStorageGb;
License = license;
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
// The token's expiration is cryptographically secured and cannot be tampered with
// The file's Expires property can be manually edited and should NOT be trusted for display
if (claimsPrincipal != null)
{
Expiration = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
}
else
{
// No token - use the license file expiration (for older licenses without tokens)
Expiration = License.Expires;
}
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount)
? new BillingCustomerDiscount(subscription!.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense? license = null) public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription") : base("subscription")
{ {

View File

@ -4,6 +4,7 @@ using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.Queries.Interfaces;
@ -30,6 +31,7 @@ public class AccountsControllerTests : IDisposable
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly ILicensingService _licensingService;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly AccountsController _sut; private readonly AccountsController _sut;
@ -40,13 +42,15 @@ public class AccountsControllerTests : IDisposable
_paymentService = Substitute.For<IPaymentService>(); _paymentService = Substitute.For<IPaymentService>();
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>(); _twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>(); _userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_licensingService = Substitute.For<ILicensingService>();
_globalSettings = new GlobalSettings { SelfHosted = false }; _globalSettings = new GlobalSettings { SelfHosted = false };
_sut = new AccountsController( _sut = new AccountsController(
_userService, _userService,
_twoFactorIsEnabledQuery, _twoFactorIsEnabledQuery,
_userAccountKeysQuery, _userAccountKeysQuery,
_featureService _featureService,
_licensingService
); );
} }