1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05:00

[PM-11516] Initial license file refactor (#5002)

* 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 <mbishop@bitwarden.com>
This commit is contained in:
Conner Turnbull
2024-12-05 09:31:14 -05:00
committed by GitHub
parent 0e32dcccad
commit 04cf513d78
23 changed files with 846 additions and 106 deletions

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
@ -13,5 +14,12 @@ public interface ILicensingService
byte[] SignLicense(ILicense license);
Task<OrganizationLicense> ReadOrganizationLicenseAsync(Organization organization);
Task<OrganizationLicense> ReadOrganizationLicenseAsync(Guid organizationId);
ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license);
Task<string> CreateOrganizationTokenAsync(
Organization organization,
Guid installationId,
SubscriptionInfo subscriptionInfo);
Task<string> CreateUserTokenAsync(User user, 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.Models;
using Bit.Core.Billing.Licenses.Services;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
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;
@ -19,27 +26,33 @@ public class LicensingService : ILicensingService
private readonly IGlobalSettings _globalSettings;
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IMailService _mailService;
private readonly ILogger<LicensingService> _logger;
private readonly ILicenseClaimsFactory<Organization> _organizationLicenseClaimsFactory;
private readonly ILicenseClaimsFactory<User> _userLicenseClaimsFactory;
private readonly IFeatureService _featureService;
private IDictionary<Guid, DateTime> _userCheckCache = new Dictionary<Guid, DateTime>();
public LicensingService(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IMailService mailService,
IWebHostEnvironment environment,
ILogger<LicensingService> logger,
IGlobalSettings globalSettings)
IGlobalSettings globalSettings,
ILicenseClaimsFactory<Organization> organizationLicenseClaimsFactory,
IFeatureService featureService,
ILicenseClaimsFactory<User> userLicenseClaimsFactory)
{
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_mailService = mailService;
_logger = logger;
_globalSettings = globalSettings;
_organizationLicenseClaimsFactory = organizationLicenseClaimsFactory;
_featureService = featureService;
_userLicenseClaimsFactory = userLicenseClaimsFactory;
var certThumbprint = environment.IsDevelopment() ?
"207E64A231E8AA32AAF68A61037C075EBEBD553F" :
@ -104,13 +117,13 @@ public class LicensingService : ILicensingService
continue;
}
if (!license.VerifyData(org, _globalSettings))
if (!license.VerifyData(org, GetClaimsPrincipalFromLicense(license), _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;
@ -203,13 +216,14 @@ public class LicensingService : ILicensingService
return false;
}
if (!license.VerifyData(user))
var claimsPrincipal = GetClaimsPrincipalFromLicense(license);
if (!license.VerifyData(user, claimsPrincipal))
{
await DisablePremiumAsync(user, license, "Invalid data.");
return false;
}
if (!license.VerifySignature(_certificate))
if (string.IsNullOrWhiteSpace(license.Token) && !license.VerifySignature(_certificate))
{
await DisablePremiumAsync(user, license, "Invalid signature.");
return false;
@ -234,7 +248,21 @@ public class LicensingService : ILicensingService
public bool VerifyLicense(ILicense license)
{
return license.VerifySignature(_certificate);
if (string.IsNullOrWhiteSpace(license.Token))
{
return license.VerifySignature(_certificate);
}
try
{
_ = GetClaimsPrincipalFromLicense(license);
return true;
}
catch (Exception e)
{
_logger.LogWarning(e, "Invalid token.");
return false;
}
}
public byte[] SignLicense(ILicense license)
@ -272,4 +300,101 @@ public class LicensingService : ILicensingService
using var fs = File.OpenRead(filePath);
return await JsonSerializer.DeserializeAsync<OrganizationLicense>(fs);
}
public ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license)
{
if (string.IsNullOrWhiteSpace(license.Token))
{
return null;
}
var audience = license switch
{
OrganizationLicense orgLicense => $"organization:{orgLicense.Id}",
UserLicense userLicense => $"user:{userLicense.Id}",
_ => throw new ArgumentException("Unsupported license type.", nameof(license)),
};
var token = license.Token;
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new X509SecurityKey(_certificate),
ValidateIssuer = true,
ValidIssuer = "bitwarden",
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
RequireExpirationTime = true
};
try
{
return tokenHandler.ValidateToken(token, validationParameters, out _);
}
catch (Exception ex)
{
// Token exceptions thrown are interpreted by the client as Identity errors and cause the user to logout
// Mask them by rethrowing as BadRequestException
throw new BadRequestException($"Invalid license. {ex.Message}");
}
}
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:{organization.Id}";
return GenerateToken(claims, audience);
}
public async Task<string> CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor))
{
return null;
}
var licenseContext = new LicenseContext { SubscriptionInfo = subscriptionInfo };
var claims = await _userLicenseClaimsFactory.GenerateClaims(user, licenseContext);
var audience = $"user:{user.Id}";
return GenerateToken(claims, audience);
}
private string GenerateToken(List<Claim> claims, string audience)
{
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 = DateTime.UtcNow.AddYears(1), // Org expiration is a claim
SigningCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature)
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}

View File

@ -908,7 +908,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
throw new BadRequestException("Invalid license.");
}
if (!license.CanUse(user, out var exceptionMessage))
var claimsPrincipal = _licenseService.GetClaimsPrincipalFromLicense(license);
if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage))
{
throw new BadRequestException(exceptionMessage);
}
@ -987,7 +989,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
throw new BadRequestException("Invalid license.");
}
if (!license.CanUse(user, out var exceptionMessage))
var claimsPrincipal = _licenseService.GetClaimsPrincipalFromLicense(license);
if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage))
{
throw new BadRequestException(exceptionMessage);
}
@ -1111,7 +1115,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
}
}
public async Task<UserLicense> GenerateLicenseAsync(User user, SubscriptionInfo subscriptionInfo = null,
public async Task<UserLicense> GenerateLicenseAsync(
User user,
SubscriptionInfo subscriptionInfo = null,
int? version = null)
{
if (user == null)
@ -1124,8 +1130,13 @@ public class UserService : UserManager<User>, IUserService, IDisposable
subscriptionInfo = await _paymentService.GetSubscriptionAsync(user);
}
return subscriptionInfo == null ? new UserLicense(user, _licenseService) :
new UserLicense(user, subscriptionInfo, _licenseService);
var userLicense = subscriptionInfo == null
? new UserLicense(user, _licenseService)
: new UserLicense(user, subscriptionInfo, _licenseService);
userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo);
return userLicense;
}
public override async Task<bool> CheckPasswordAsync(User user, string password)

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Core.Settings;
@ -53,4 +54,19 @@ public class NoopLicensingService : ILicensingService
{
return Task.FromResult<OrganizationLicense>(null);
}
public ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license)
{
return null;
}
public Task<string> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo)
{
return Task.FromResult<string>(null);
}
public Task<string> CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo)
{
return Task.FromResult<string>(null);
}
}