449 lines
15 KiB
C#
449 lines
15 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using IronLicensing.Client.Exceptions;
|
|
using IronLicensing.Client.Models;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace IronLicensing.Client;
|
|
|
|
public class LicenseManager : ILicenseManager
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly LicensingOptions _options;
|
|
private readonly IMachineIdentifier _machineIdentifier;
|
|
private readonly ILicenseCache _cache;
|
|
private readonly ISignatureVerifier _signatureVerifier;
|
|
|
|
private LicenseInfo? _currentLicense;
|
|
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
|
|
|
public LicenseInfo? CurrentLicense => _currentLicense;
|
|
|
|
public LicenseStatus Status => _currentLicense?.Status ?? LicenseStatus.Unknown;
|
|
|
|
public bool IsLicensed => _currentLicense != null &&
|
|
(_currentLicense.Status == LicenseStatus.Active || _currentLicense.Status == LicenseStatus.Trial);
|
|
|
|
public bool IsTrial => _currentLicense?.Status == LicenseStatus.Trial;
|
|
|
|
public int TrialDaysRemaining
|
|
{
|
|
get
|
|
{
|
|
if (_currentLicense?.Status != LicenseStatus.Trial || !_currentLicense.ExpiresAt.HasValue)
|
|
return 0;
|
|
var remaining = (_currentLicense.ExpiresAt.Value - DateTime.UtcNow).Days;
|
|
return Math.Max(0, remaining);
|
|
}
|
|
}
|
|
|
|
public event EventHandler<LicenseChangedEventArgs>? LicenseChanged;
|
|
public event EventHandler<LicenseValidationEventArgs>? ValidationCompleted;
|
|
|
|
public LicenseManager(
|
|
HttpClient httpClient,
|
|
IOptions<LicensingOptions> options,
|
|
IMachineIdentifier machineIdentifier,
|
|
ILicenseCache cache,
|
|
ISignatureVerifier signatureVerifier)
|
|
{
|
|
_httpClient = httpClient;
|
|
_options = options.Value;
|
|
_machineIdentifier = machineIdentifier;
|
|
_cache = cache;
|
|
_signatureVerifier = signatureVerifier;
|
|
|
|
ConfigureHttpClient();
|
|
}
|
|
|
|
private void ConfigureHttpClient()
|
|
{
|
|
_httpClient.BaseAddress = new Uri(_options.ApiBaseUrl.TrimEnd('/') + "/");
|
|
_httpClient.DefaultRequestHeaders.Add("X-Public-Key", _options.PublicKey);
|
|
_httpClient.DefaultRequestHeaders.Add("X-Product-Slug", _options.ProductSlug);
|
|
_httpClient.Timeout = _options.HttpTimeout;
|
|
}
|
|
|
|
public async Task<LicenseResult> ValidateAsync(string? licenseKey = null)
|
|
{
|
|
await _syncLock.WaitAsync();
|
|
try
|
|
{
|
|
var key = licenseKey ?? _currentLicense?.LicenseKey;
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
// Try to load from cache
|
|
var cached = await _cache.GetCachedLicenseAsync();
|
|
if (cached != null)
|
|
{
|
|
key = cached.License.LicenseKey;
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return LicenseResult.Fail("NO_LICENSE", "No license key provided");
|
|
}
|
|
|
|
try
|
|
{
|
|
var request = new
|
|
{
|
|
licenseKey = key,
|
|
machineId = _machineIdentifier.GetMachineId(),
|
|
machineName = _machineIdentifier.GetMachineName(),
|
|
platform = _machineIdentifier.GetPlatform(),
|
|
appVersion = GetAppVersion()
|
|
};
|
|
|
|
var response = await _httpClient.PostAsJsonAsync("licenses/validate", request);
|
|
var result = await response.Content.ReadFromJsonAsync<ValidationResponse>();
|
|
|
|
if (result == null)
|
|
{
|
|
return await HandleOfflineValidation(key);
|
|
}
|
|
|
|
if (result.Valid && result.License != null)
|
|
{
|
|
var licenseInfo = MapToLicenseInfo(key, result.License);
|
|
await UpdateLicense(licenseInfo);
|
|
|
|
// Cache with signature for offline validation
|
|
var rawJson = JsonSerializer.Serialize(result.License);
|
|
await _cache.SaveLicenseAsync(licenseInfo, result.Signature, result.SigningPublicKey);
|
|
|
|
// Save signing public key if provided
|
|
if (!string.IsNullOrEmpty(result.SigningPublicKey))
|
|
{
|
|
await _cache.SaveSigningPublicKeyAsync(result.SigningPublicKey);
|
|
}
|
|
|
|
RaiseValidationCompleted(true, licenseInfo, null, null);
|
|
return LicenseResult.Ok(licenseInfo);
|
|
}
|
|
else
|
|
{
|
|
RaiseValidationCompleted(false, null, result.Error?.Code, result.Error?.Message);
|
|
return LicenseResult.Fail(result.Error?.Code ?? "UNKNOWN", result.Error?.Message ?? "Validation failed");
|
|
}
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
return await HandleOfflineValidation(key);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return await HandleOfflineValidation(key);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_syncLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<LicenseResult> HandleOfflineValidation(string licenseKey)
|
|
{
|
|
var cached = await _cache.GetCachedLicenseAsync();
|
|
if (cached == null || cached.License.LicenseKey != licenseKey)
|
|
{
|
|
RaiseValidationCompleted(false, null, "OFFLINE", "Cannot validate license while offline");
|
|
return LicenseResult.Fail("OFFLINE", "Cannot validate license while offline");
|
|
}
|
|
|
|
// Check offline grace period
|
|
var daysSinceValidation = (DateTime.UtcNow - cached.License.LastValidated).TotalDays;
|
|
if (daysSinceValidation > _options.OfflineGraceDays)
|
|
{
|
|
RaiseValidationCompleted(false, null, "OFFLINE_EXPIRED", "Offline grace period expired. Please connect to the internet to validate your license.");
|
|
return LicenseResult.Fail("OFFLINE_EXPIRED", "Offline grace period expired. Please connect to the internet to validate your license.");
|
|
}
|
|
|
|
// Verify signature if required
|
|
if (_options.RequireSignatureForOffline)
|
|
{
|
|
var publicKey = _options.SigningPublicKey ?? cached.SigningPublicKey ?? await _cache.GetSigningPublicKeyAsync();
|
|
|
|
if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(cached.Signature) || string.IsNullOrEmpty(cached.RawLicenseJson))
|
|
{
|
|
// No signature available - check if we allow this
|
|
if (_options.RequireSignatureForOffline)
|
|
{
|
|
RaiseValidationCompleted(false, null, "NO_SIGNATURE", "Offline validation requires a signed license");
|
|
return LicenseResult.Fail("NO_SIGNATURE", "Offline validation requires a signed license");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Verify signature
|
|
var isValid = _signatureVerifier.Verify(publicKey, cached.RawLicenseJson, cached.Signature);
|
|
if (!isValid)
|
|
{
|
|
RaiseValidationCompleted(false, null, "INVALID_SIGNATURE", "License signature verification failed");
|
|
return LicenseResult.Fail("INVALID_SIGNATURE", "License signature verification failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Valid offline license
|
|
await UpdateLicense(cached.License);
|
|
RaiseValidationCompleted(true, cached.License, null, null);
|
|
return LicenseResult.Ok(cached.License);
|
|
}
|
|
|
|
public async Task<LicenseResult> ActivateAsync(string licenseKey)
|
|
{
|
|
await _syncLock.WaitAsync();
|
|
try
|
|
{
|
|
var request = new
|
|
{
|
|
licenseKey,
|
|
machineId = _machineIdentifier.GetMachineId(),
|
|
machineName = _machineIdentifier.GetMachineName(),
|
|
platform = _machineIdentifier.GetPlatform(),
|
|
appVersion = GetAppVersion()
|
|
};
|
|
|
|
var response = await _httpClient.PostAsJsonAsync("licenses/activate", request);
|
|
var result = await response.Content.ReadFromJsonAsync<ActivationResponse>();
|
|
|
|
if (result?.Success == true)
|
|
{
|
|
// Release lock before calling ValidateAsync (it acquires its own lock)
|
|
_syncLock.Release();
|
|
try
|
|
{
|
|
return await ValidateAsync(licenseKey);
|
|
}
|
|
finally
|
|
{
|
|
await _syncLock.WaitAsync();
|
|
}
|
|
}
|
|
|
|
return LicenseResult.Fail(result?.Error?.Code ?? "UNKNOWN", result?.Error?.Message ?? "Activation failed");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return LicenseResult.Fail("ERROR", ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
_syncLock.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<bool> DeactivateAsync()
|
|
{
|
|
if (_currentLicense == null)
|
|
return false;
|
|
|
|
await _syncLock.WaitAsync();
|
|
try
|
|
{
|
|
var request = new
|
|
{
|
|
licenseKey = _currentLicense.LicenseKey,
|
|
machineId = _machineIdentifier.GetMachineId()
|
|
};
|
|
|
|
var response = await _httpClient.PostAsJsonAsync("licenses/deactivate", request);
|
|
var result = await response.Content.ReadFromJsonAsync<ActivationResponse>();
|
|
|
|
if (result?.Success == true)
|
|
{
|
|
await ClearCacheAsync();
|
|
await UpdateLicense(null);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_syncLock.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<CheckoutResult> StartPurchaseAsync(string tierId, string customerEmail)
|
|
{
|
|
try
|
|
{
|
|
var request = new
|
|
{
|
|
tierId,
|
|
customerEmail,
|
|
successUrl = _options.SuccessUrl,
|
|
cancelUrl = _options.CancelUrl,
|
|
metadata = new Dictionary<string, string>
|
|
{
|
|
["machineId"] = _machineIdentifier.GetMachineId()
|
|
}
|
|
};
|
|
|
|
var response = await _httpClient.PostAsJsonAsync("checkout/create", request);
|
|
var result = await response.Content.ReadFromJsonAsync<CheckoutApiResponse>();
|
|
|
|
if (result != null)
|
|
{
|
|
return new CheckoutResult
|
|
{
|
|
Success = true,
|
|
SessionId = result.SessionId,
|
|
CheckoutUrl = result.CheckoutUrl,
|
|
ExpiresAt = result.ExpiresAt
|
|
};
|
|
}
|
|
|
|
return new CheckoutResult { Success = false, ErrorMessage = "Failed to create checkout session" };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new CheckoutResult { Success = false, ErrorMessage = ex.Message };
|
|
}
|
|
}
|
|
|
|
public bool HasFeature(string featureKey)
|
|
{
|
|
if (_currentLicense == null)
|
|
return false;
|
|
|
|
return _currentLicense.Features.Contains(featureKey) || _currentLicense.Features.Contains("*");
|
|
}
|
|
|
|
public async Task<bool> HasFeatureAsync(string featureKey)
|
|
{
|
|
if (_currentLicense == null)
|
|
return false;
|
|
|
|
// Check local first
|
|
if (HasFeature(featureKey))
|
|
return true;
|
|
|
|
// Check with server
|
|
try
|
|
{
|
|
var response = await _httpClient.GetAsync($"licenses/{_currentLicense.LicenseKey}/features/{featureKey}");
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
|
return result.TryGetProperty("enabled", out var enabled) && enabled.GetBoolean();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Fall back to local check
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public void RequireFeature(string featureKey)
|
|
{
|
|
if (!HasFeature(featureKey))
|
|
{
|
|
throw new LicenseRequiredException(featureKey);
|
|
}
|
|
}
|
|
|
|
public async Task<bool> StartTrialAsync(string email)
|
|
{
|
|
try
|
|
{
|
|
var request = new
|
|
{
|
|
email,
|
|
machineId = _machineIdentifier.GetMachineId(),
|
|
machineName = _machineIdentifier.GetMachineName(),
|
|
platform = _machineIdentifier.GetPlatform()
|
|
};
|
|
|
|
var response = await _httpClient.PostAsJsonAsync("trial/start", request);
|
|
var result = await response.Content.ReadFromJsonAsync<TrialResponse>();
|
|
|
|
if (result?.Success == true && !string.IsNullOrEmpty(result.LicenseKey))
|
|
{
|
|
// Activate the trial license
|
|
var activationResult = await ActivateAsync(result.LicenseKey);
|
|
return activationResult.Success;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task ClearCacheAsync()
|
|
{
|
|
await _cache.ClearAsync();
|
|
}
|
|
|
|
private async Task UpdateLicense(LicenseInfo? license)
|
|
{
|
|
var oldLicense = _currentLicense;
|
|
_currentLicense = license;
|
|
|
|
LicenseChanged?.Invoke(this, new LicenseChangedEventArgs
|
|
{
|
|
OldLicense = oldLicense,
|
|
NewLicense = license
|
|
});
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private void RaiseValidationCompleted(bool success, LicenseInfo? license, string? errorCode, string? errorMessage)
|
|
{
|
|
ValidationCompleted?.Invoke(this, new LicenseValidationEventArgs
|
|
{
|
|
Success = success,
|
|
License = license,
|
|
ErrorCode = errorCode,
|
|
ErrorMessage = errorMessage
|
|
});
|
|
}
|
|
|
|
private static LicenseInfo MapToLicenseInfo(string licenseKey, ValidationLicenseInfo validation)
|
|
{
|
|
return new LicenseInfo
|
|
{
|
|
LicenseKey = licenseKey,
|
|
Tier = validation.Tier,
|
|
Status = ParseStatus(validation.Status),
|
|
ExpiresAt = validation.ExpiresAt,
|
|
Features = validation.Features,
|
|
ActivationsUsed = validation.ActivationsUsed,
|
|
ActivationsMax = validation.ActivationsMax,
|
|
LastValidated = DateTime.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LicenseStatus ParseStatus(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"trial" => LicenseStatus.Trial,
|
|
"active" => LicenseStatus.Active,
|
|
"expired" => LicenseStatus.Expired,
|
|
"suspended" => LicenseStatus.Suspended,
|
|
"revoked" => LicenseStatus.Revoked,
|
|
_ => LicenseStatus.Unknown
|
|
};
|
|
}
|
|
|
|
private static string GetAppVersion()
|
|
{
|
|
var assembly = System.Reflection.Assembly.GetEntryAssembly();
|
|
return assembly?.GetName().Version?.ToString() ?? "1.0.0";
|
|
}
|
|
}
|