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

Added ability to consume JWT licenses

This commit is contained in:
Conner Turnbull 2024-11-04 14:58:45 -05:00
parent a473487e19
commit 6fa8e5afeb
No known key found for this signature in database
GPG Key ID: D42CA06D8EB866CC
5 changed files with 442 additions and 79 deletions

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Models.Business;
@ -6,7 +8,6 @@ namespace Bit.Core.Billing.Licenses.Extensions;
public static class LicenseExtensions
{
public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo)
{
if (subscriptionInfo?.Subscription == null)
@ -75,4 +76,30 @@ public static class LicenseExtensions
return expirationDate;
}
public static ClaimsPrincipal ToClaimsPrincipal(this string token)
{
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var jwtSecurityToken = jwtSecurityTokenHandler.ReadJwtToken(token);
if (jwtSecurityToken is null)
{
throw new ArgumentException("Invalid token.", nameof(token));
}
var claimsIdentity = new ClaimsIdentity(jwtSecurityToken.Claims, "BitwardenLicense");
return new ClaimsPrincipal(claimsIdentity);
}
public static T GetValue<T>(this ClaimsPrincipal principal, string claimType)
{
var claim = principal.FindFirst(claimType);
if (claim is null)
{
return default;
}
return (T)Convert.ChangeType(claim.Value, typeof(T));
}
}

View File

@ -12,6 +12,7 @@ public interface ILicense
bool Trial { get; set; }
string Hash { get; set; }
string Signature { get; set; }
string Token { get; set; }
byte[] SignatureBytes { get; }
byte[] GetDataBytes(bool forHash = false);
byte[] ComputeHash();

View File

@ -5,6 +5,7 @@ using System.Text;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -239,6 +240,58 @@ public class OrganizationLicense : ILicense
}
public bool CanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception)
{
if (string.IsNullOrWhiteSpace(Token))
{
return ObsoleteCanUse(globalSettings, licensingService, out exception);
}
var errorMessages = new StringBuilder();
var enabled = Token.ToClaimsPrincipal().GetValue<bool>(nameof(Enabled));
if (!enabled)
{
errorMessages.AppendLine("Your cloud-hosted organization is currently disabled.");
}
var installationId = Token.ToClaimsPrincipal().GetValue<Guid>(nameof(InstallationId));
if (installationId != globalSettings.Installation.Id)
{
errorMessages.AppendLine("The installation ID does not match the current installation.");
}
var selfHost = Token.ToClaimsPrincipal().GetValue<bool>(nameof(SelfHost));
if (!selfHost)
{
errorMessages.AppendLine("The license does not allow for on-premise hosting of organizations.");
}
var licenseType = Token.ToClaimsPrincipal().GetValue<LicenseType>(nameof(LicenseType));
if (licenseType != Enums.LicenseType.Organization)
{
errorMessages.AppendLine("Premium licenses cannot be applied to an organization. " +
"Upload this license from your personal account settings page.");
}
if (errorMessages.Length > 0)
{
exception = $"Invalid license. {errorMessages.ToString().TrimEnd()}";
return false;
}
exception = "";
return true;
}
/// <summary>
/// Do not extend this method. It is only here for backwards compatibility with old licenses.
/// Instead, extend the CanUse method using the ClaimsPrincipal.
/// </summary>
/// <param name="globalSettings"></param>
/// <param name="licensingService"></param>
/// <param name="exception"></param>
/// <returns></returns>
private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception)
{
var errorMessages = new StringBuilder();
@ -294,100 +347,281 @@ public class OrganizationLicense : ILicense
}
public bool VerifyData(Organization organization, IGlobalSettings globalSettings)
{
if (string.IsNullOrWhiteSpace(Token))
{
return ObsoleteVerifyData(organization, globalSettings);
}
// It looks a little goofy to extend this file instead of moving this logic to a command, but in an effort
// to keep changes low in each PR, we'll move this to a command in a subsequent PR.
// The end goal will be to not have an OrganizationLicense file whatsoever, and instead simply rely on the token
var claimsPrincipal = Token.ToClaimsPrincipal();
var issued = claimsPrincipal.GetValue<DateTime>(nameof(Issued));
var expires = claimsPrincipal.GetValue<DateTime>(nameof(Expires));
if (issued > DateTime.UtcNow || expires < DateTime.UtcNow)
{
return false;
}
var installationId = claimsPrincipal.GetValue<Guid>(nameof(InstallationId));
if (installationId != globalSettings.Installation.Id)
{
return false;
}
var licenseKey = claimsPrincipal.GetValue<string>(nameof(LicenseKey));
if (licenseKey != organization.LicenseKey)
{
return false;
}
var enabled = claimsPrincipal.GetValue<bool>(nameof(Enabled));
if (enabled != organization.Enabled)
{
return false;
}
var planType = claimsPrincipal.GetValue<PlanType>(nameof(PlanType));
if (planType != organization.PlanType)
{
return false;
}
var seats = claimsPrincipal.GetValue<int?>(nameof(Seats));
if (seats != organization.Seats)
{
return false;
}
var maxCollections = claimsPrincipal.GetValue<short?>(nameof(MaxCollections));
if (maxCollections != organization.MaxCollections)
{
return false;
}
var useGroups = claimsPrincipal.GetValue<bool>(nameof(UseGroups));
if (useGroups != organization.UseGroups)
{
return false;
}
var useDirectory = claimsPrincipal.GetValue<bool>(nameof(UseDirectory));
if (useDirectory != organization.UseDirectory)
{
return false;
}
var useTotp = claimsPrincipal.GetValue<bool>(nameof(UseTotp));
if (useTotp != organization.UseTotp)
{
return false;
}
var selfHost = claimsPrincipal.GetValue<bool>(nameof(SelfHost));
if (selfHost != organization.SelfHost)
{
return false;
}
var name = claimsPrincipal.GetValue<string>(nameof(Name));
if (name != organization.Name)
{
return false;
}
var usersGetPremium = claimsPrincipal.GetValue<bool>(nameof(UsersGetPremium));
if (usersGetPremium != organization.UsersGetPremium)
{
return false;
}
var useEvents = claimsPrincipal.GetValue<bool>(nameof(UseEvents));
if (useEvents != organization.UseEvents)
{
return false;
}
var use2fa = claimsPrincipal.GetValue<bool>(nameof(Use2fa));
if (use2fa != organization.Use2fa)
{
return false;
}
var useApi = claimsPrincipal.GetValue<bool>(nameof(UseApi));
if (useApi != organization.UseApi)
{
return false;
}
var usePolicies = claimsPrincipal.GetValue<bool>(nameof(UsePolicies));
if (usePolicies != organization.UsePolicies)
{
return false;
}
var useSso = claimsPrincipal.GetValue<bool>(nameof(UseSso));
if (useSso != organization.UseSso)
{
return false;
}
var useResetPassword = claimsPrincipal.GetValue<bool>(nameof(UseResetPassword));
if (useResetPassword != organization.UseResetPassword)
{
return false;
}
var useKeyConnector = claimsPrincipal.GetValue<bool>(nameof(UseKeyConnector));
if (useKeyConnector != organization.UseKeyConnector)
{
return false;
}
var useScim = claimsPrincipal.GetValue<bool>(nameof(UseScim));
if (useScim != organization.UseScim)
{
return false;
}
var useCustomPermissions = claimsPrincipal.GetValue<bool>(nameof(UseCustomPermissions));
if (useCustomPermissions != organization.UseCustomPermissions)
{
return false;
}
var useSecretsManager = claimsPrincipal.GetValue<bool>(nameof(UseSecretsManager));
if (useSecretsManager != organization.UseSecretsManager)
{
return false;
}
var usePasswordManager = claimsPrincipal.GetValue<bool>(nameof(UsePasswordManager));
if (usePasswordManager != organization.UsePasswordManager)
{
return false;
}
var smSeats = claimsPrincipal.GetValue<int?>(nameof(SmSeats));
if (smSeats != organization.SmSeats)
{
return false;
}
var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts));
if (smServiceAccounts != organization.SmServiceAccounts)
{
return false;
}
return true;
}
/// <summary>
/// Do not extend this method. It is only here for backwards compatibility with old licenses.
/// Instead, extend the CanUse method using the ClaimsPrincipal.
/// </summary>
/// <param name="organization"></param>
/// <param name="globalSettings"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
private bool ObsoleteVerifyData(Organization organization, IGlobalSettings globalSettings)
{
if (Issued > DateTime.UtcNow || Expires < DateTime.UtcNow)
{
return false;
}
if (ValidLicenseVersion)
if (!ValidLicenseVersion)
{
var valid =
globalSettings.Installation.Id == InstallationId &&
organization.LicenseKey != null && organization.LicenseKey.Equals(LicenseKey) &&
organization.Enabled == Enabled &&
organization.PlanType == PlanType &&
organization.Seats == Seats &&
organization.MaxCollections == MaxCollections &&
organization.UseGroups == UseGroups &&
organization.UseDirectory == UseDirectory &&
organization.UseTotp == UseTotp &&
organization.SelfHost == SelfHost &&
organization.Name.Equals(Name);
throw new NotSupportedException($"Version {Version} is not supported.");
}
if (valid && Version >= 2)
{
valid = organization.UsersGetPremium == UsersGetPremium;
}
var valid =
globalSettings.Installation.Id == InstallationId &&
organization.LicenseKey != null && organization.LicenseKey.Equals(LicenseKey) &&
organization.Enabled == Enabled &&
organization.PlanType == PlanType &&
organization.Seats == Seats &&
organization.MaxCollections == MaxCollections &&
organization.UseGroups == UseGroups &&
organization.UseDirectory == UseDirectory &&
organization.UseTotp == UseTotp &&
organization.SelfHost == SelfHost &&
organization.Name.Equals(Name);
if (valid && Version >= 3)
{
valid = organization.UseEvents == UseEvents;
}
if (valid && Version >= 2)
{
valid = organization.UsersGetPremium == UsersGetPremium;
}
if (valid && Version >= 4)
{
valid = organization.Use2fa == Use2fa;
}
if (valid && Version >= 3)
{
valid = organization.UseEvents == UseEvents;
}
if (valid && Version >= 5)
{
valid = organization.UseApi == UseApi;
}
if (valid && Version >= 4)
{
valid = organization.Use2fa == Use2fa;
}
if (valid && Version >= 6)
{
valid = organization.UsePolicies == UsePolicies;
}
if (valid && Version >= 5)
{
valid = organization.UseApi == UseApi;
}
if (valid && Version >= 7)
{
valid = organization.UseSso == UseSso;
}
if (valid && Version >= 6)
{
valid = organization.UsePolicies == UsePolicies;
}
if (valid && Version >= 8)
{
valid = organization.UseResetPassword == UseResetPassword;
}
if (valid && Version >= 7)
{
valid = organization.UseSso == UseSso;
}
if (valid && Version >= 9)
{
valid = organization.UseKeyConnector == UseKeyConnector;
}
if (valid && Version >= 8)
{
valid = organization.UseResetPassword == UseResetPassword;
}
if (valid && Version >= 10)
{
valid = organization.UseScim == UseScim;
}
if (valid && Version >= 9)
{
valid = organization.UseKeyConnector == UseKeyConnector;
}
if (valid && Version >= 11)
{
valid = organization.UseCustomPermissions == UseCustomPermissions;
}
if (valid && Version >= 10)
{
valid = organization.UseScim == UseScim;
}
/*Version 12 added ExpirationWithoutDatePeriod, but that property is informational only and is not saved
if (valid && Version >= 11)
{
valid = organization.UseCustomPermissions == UseCustomPermissions;
}
/*Version 12 added ExpirationWithoutDatePeriod, but that property is informational only and is not saved
to the Organization object. It's validated as part of the hash but does not need to be validated here.
*/
if (valid && Version >= 13)
{
valid = organization.UseSecretsManager == UseSecretsManager &&
organization.UsePasswordManager == UsePasswordManager &&
organization.SmSeats == SmSeats &&
organization.SmServiceAccounts == SmServiceAccounts;
}
if (valid && Version >= 13)
{
valid = organization.UseSecretsManager == UseSecretsManager &&
organization.UsePasswordManager == UsePasswordManager &&
organization.SmSeats == SmSeats &&
organization.SmServiceAccounts == SmServiceAccounts;
}
/*
/*
* Version 14 added LimitCollectionCreationDeletion and Version
* 15 added AllowAdminAccessToAllCollectionItems, however they
* are no longer used and are intentionally excluded from
* validation.
*/
return valid;
}
throw new NotSupportedException($"Version {Version} is not supported.");
return valid;
}
public bool VerifySignature(X509Certificate2 certificate)

View File

@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
@ -116,6 +117,46 @@ public class UserLicense : ILicense
}
public bool CanUse(User user, out string exception)
{
if (string.IsNullOrWhiteSpace(Token))
{
return ObsoleteCanUse(user, out exception);
}
var errorMessages = new StringBuilder();
var claimsPrincipal = Token.ToClaimsPrincipal();
var emailVerified = claimsPrincipal.GetValue<bool>(nameof(User.EmailVerified));
if (!emailVerified)
{
errorMessages.AppendLine("The user's email is not verified.");
}
var email = claimsPrincipal.GetValue<string>(nameof(Email));
if (!email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
errorMessages.AppendLine("The user's email does not match the license email.");
}
if (errorMessages.Length > 0)
{
exception = $"Invalid license. {errorMessages.ToString().TrimEnd()}";
return false;
}
exception = "";
return true;
}
/// <summary>
/// Do not extend this method. It is only here for backwards compatibility with old licenses.
/// Instead, extend the CanUse method using the ClaimsPrincipal.
/// </summary>
/// <param name="user"></param>
/// <param name="exception"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
private bool ObsoleteCanUse(User user, out string exception)
{
var errorMessages = new StringBuilder();
@ -155,21 +196,58 @@ public class UserLicense : ILicense
}
public bool VerifyData(User user)
{
if (string.IsNullOrWhiteSpace(Token))
{
return ObsoleteVerifyData(user);
}
var claimsPrincipal = Token.ToClaimsPrincipal();
var licenseKey = claimsPrincipal.GetValue<string>(nameof(LicenseKey));
if (licenseKey != user.LicenseKey)
{
return false;
}
var premium = claimsPrincipal.GetValue<bool>(nameof(Premium));
if (premium != user.Premium)
{
return false;
}
var email = claimsPrincipal.GetValue<string>(nameof(Email));
if (!email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
return false;
}
return true;
}
/// <summary>
/// Do not extend this method. It is only here for backwards compatibility with old licenses.
/// Instead, extend the CanUse method using the ClaimsPrincipal.
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
private bool ObsoleteVerifyData(User user)
{
if (Issued > DateTime.UtcNow || Expires < DateTime.UtcNow)
{
return false;
}
if (Version == 1)
if (Version != 1)
{
return
user.LicenseKey != null && user.LicenseKey.Equals(LicenseKey) &&
user.Premium == Premium &&
user.Email.Equals(Email, StringComparison.InvariantCultureIgnoreCase);
throw new NotSupportedException($"Version {Version} is not supported.");
}
throw new NotSupportedException($"Version {Version} is not supported.");
return
user.LicenseKey != null && user.LicenseKey.Equals(LicenseKey) &&
user.Premium == Premium &&
user.Email.Equals(Email, StringComparison.InvariantCultureIgnoreCase);
}
public bool VerifySignature(X509Certificate2 certificate)

View File

@ -117,13 +117,19 @@ public class LicensingService : ILicensingService
continue;
}
if (!string.IsNullOrWhiteSpace(license.Token) && !VerifyToken(license.Token))
{
await DisableOrganizationAsync(org, license, "Invalid token.");
continue;
}
if (!license.VerifyData(org, _globalSettings))
{
await DisableOrganizationAsync(org, license, "Invalid data.");
continue;
}
if (!license.VerifySignature(_certificate))
if (string.IsNullOrWhiteSpace(license.Token) && !license.VerifySignature(_certificate))
{
await DisableOrganizationAsync(org, license, "Invalid signature.");
continue;
@ -222,7 +228,7 @@ public class LicensingService : ILicensingService
return false;
}
if (!license.VerifySignature(_certificate))
if (string.IsNullOrWhiteSpace(license.Token) && !license.VerifySignature(_certificate))
{
await DisablePremiumAsync(user, license, "Invalid signature.");
return false;
@ -247,7 +253,9 @@ public class LicensingService : ILicensingService
public bool VerifyLicense(ILicense license)
{
return license.VerifySignature(_certificate);
return string.IsNullOrWhiteSpace(license.Token)
? license.VerifySignature(_certificate)
: VerifyToken(license.Token);
}
public byte[] SignLicense(ILicense license)
@ -300,8 +308,9 @@ public class LicensingService : ILicensingService
};
var claims = await _organizationLicenseClaimsFactory.GenerateClaims(organization, licenseContext);
var audience = organization.Id.ToString();
var audience = $"organization:{organization.Id}";
var expires = organization.CalculateFreshExpirationDate(subscriptionInfo);
return GenerateToken(claims, audience, expires);
}
@ -314,7 +323,7 @@ public class LicensingService : ILicensingService
var licenseContext = new LicenseContext { SubscriptionInfo = subscriptionInfo };
var claims = await _userLicenseClaimsFactory.GenerateClaims(user, licenseContext);
var audience = user.Id.ToString();
var audience = $"user:{user.Id}";
var expires = user.PremiumExpirationDate ?? DateTime.UtcNow.AddDays(7);
return GenerateToken(claims, audience, expires);
@ -342,4 +351,18 @@ public class LicensingService : ILicensingService
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private bool VerifyToken(string token)
{
try
{
_ = token.ToClaimsPrincipal();
return true;
}
catch (Exception e)
{
_logger.LogWarning(e, "Invalid token.");
return false;
}
}
}