feat: 增加 EasyFlow license 启动校验能力
- 新增 license 解析、验签、机器指纹校验与失败日志分流 - 支持开发态 classpath 读取与打包排除 lic 资源
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.context.EnvironmentAware;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.PriorityOrdered;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* EasyFlow 启动前的 license 强制校验器。
|
||||
*/
|
||||
@Component
|
||||
public class EasyflowLicenseBootstrapValidator implements BeanFactoryPostProcessor, EnvironmentAware, PriorityOrdered {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EasyflowLicenseBootstrapValidator.class);
|
||||
|
||||
private final EasyflowLicenseVerifier easyflowLicenseVerifier;
|
||||
private String location;
|
||||
|
||||
/**
|
||||
* 构造启动前校验器。
|
||||
*/
|
||||
public EasyflowLicenseBootstrapValidator() {
|
||||
this(new EasyflowLicenseVerifier(new DefaultResourceLoader(), new MachineIdentityCollector()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造启动前校验器。
|
||||
*
|
||||
* @param easyflowLicenseVerifier license 校验器
|
||||
*/
|
||||
EasyflowLicenseBootstrapValidator(EasyflowLicenseVerifier easyflowLicenseVerifier) {
|
||||
this.easyflowLicenseVerifier = easyflowLicenseVerifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从环境中读取 license 配置。
|
||||
*
|
||||
* @param environment Spring 环境
|
||||
*/
|
||||
@Override
|
||||
public void setEnvironment(Environment environment) {
|
||||
this.location = environment.getProperty("easyflow.license.location");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 BeanFactory 初始化阶段执行 license 校验。
|
||||
*
|
||||
* @param beanFactory BeanFactory
|
||||
* @throws BeansException 校验失败
|
||||
*/
|
||||
@Override
|
||||
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
|
||||
try {
|
||||
EasyflowLicenseVerificationResult result = easyflowLicenseVerifier.verify(location);
|
||||
LOG.info("license 校验通过: location={}, licenseId={}, keyId={}, licenseType={}, expiresAt={}",
|
||||
result.location(),
|
||||
result.licenseId(),
|
||||
result.keyId(),
|
||||
result.licenseType(),
|
||||
result.expiresAt());
|
||||
} catch (EasyflowLicenseException ex) {
|
||||
if (ex.isMissing()) {
|
||||
LOG.error("lic 文件不存在: location={}, reason={}",
|
||||
ex.getLocation(),
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
} else {
|
||||
LOG.error("license 文件校验失败: location={}, licenseId={}, keyId={}, reason={}",
|
||||
ex.getLocation(),
|
||||
ex.getLicenseId(),
|
||||
ex.getKeyId(),
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
}
|
||||
throw new IllegalStateException(ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以最高优先级执行,先于其他启动扩展。
|
||||
*
|
||||
* @return 执行顺序
|
||||
*/
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.HIGHEST_PRECEDENCE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
/**
|
||||
* EasyFlow license 校验异常。
|
||||
*/
|
||||
public class EasyflowLicenseException extends RuntimeException {
|
||||
|
||||
private final Reason reason;
|
||||
private final String location;
|
||||
private final String licenseId;
|
||||
private final String keyId;
|
||||
|
||||
/**
|
||||
* 构造 license 校验异常。
|
||||
*
|
||||
* @param reason 异常类型
|
||||
* @param message 异常消息
|
||||
* @param location license 位置
|
||||
* @param licenseId licenseId
|
||||
* @param keyId keyId
|
||||
* @param cause 原始异常
|
||||
*/
|
||||
public EasyflowLicenseException(Reason reason,
|
||||
String message,
|
||||
String location,
|
||||
String licenseId,
|
||||
String keyId,
|
||||
Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
this.location = location;
|
||||
this.licenseId = licenseId;
|
||||
this.keyId = keyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建缺失类异常。
|
||||
*
|
||||
* @param message 异常消息
|
||||
* @param location license 位置
|
||||
* @return 异常实例
|
||||
*/
|
||||
public static EasyflowLicenseException missing(String message, String location) {
|
||||
return new EasyflowLicenseException(Reason.MISSING, message, location, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建缺失类异常。
|
||||
*
|
||||
* @param message 异常消息
|
||||
* @param location license 位置
|
||||
* @param cause 原始异常
|
||||
* @return 异常实例
|
||||
*/
|
||||
public static EasyflowLicenseException missing(String message, String location, Throwable cause) {
|
||||
return new EasyflowLicenseException(Reason.MISSING, message, location, null, null, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建校验失败异常。
|
||||
*
|
||||
* @param message 异常消息
|
||||
* @param location license 位置
|
||||
* @param licenseId licenseId
|
||||
* @param keyId keyId
|
||||
* @return 异常实例
|
||||
*/
|
||||
public static EasyflowLicenseException invalid(String message, String location, String licenseId, String keyId) {
|
||||
return new EasyflowLicenseException(Reason.INVALID, message, location, licenseId, keyId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建校验失败异常。
|
||||
*
|
||||
* @param message 异常消息
|
||||
* @param location license 位置
|
||||
* @param licenseId licenseId
|
||||
* @param keyId keyId
|
||||
* @param cause 原始异常
|
||||
* @return 异常实例
|
||||
*/
|
||||
public static EasyflowLicenseException invalid(String message,
|
||||
String location,
|
||||
String licenseId,
|
||||
String keyId,
|
||||
Throwable cause) {
|
||||
return new EasyflowLicenseException(Reason.INVALID, message, location, licenseId, keyId, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异常类型。
|
||||
*
|
||||
* @return 异常类型
|
||||
*/
|
||||
public Reason getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 license 位置。
|
||||
*
|
||||
* @return license 位置
|
||||
*/
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 licenseId。
|
||||
*
|
||||
* @return licenseId
|
||||
*/
|
||||
public String getLicenseId() {
|
||||
return licenseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 keyId。
|
||||
*
|
||||
* @return keyId
|
||||
*/
|
||||
public String getKeyId() {
|
||||
return keyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为缺失类异常。
|
||||
*
|
||||
* @return 是否缺失
|
||||
*/
|
||||
public boolean isMissing() {
|
||||
return reason == Reason.MISSING;
|
||||
}
|
||||
|
||||
/**
|
||||
* License 异常类型。
|
||||
*/
|
||||
public enum Reason {
|
||||
MISSING,
|
||||
INVALID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* EasyFlow license 配置属性。
|
||||
*/
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "easyflow.license")
|
||||
public class EasyflowLicenseProperties {
|
||||
|
||||
/**
|
||||
* License 资源位置,支持 classpath: 与 file: 形式。
|
||||
*/
|
||||
private String location;
|
||||
|
||||
/**
|
||||
* 获取 license 资源位置。
|
||||
*
|
||||
* @return license 资源位置
|
||||
*/
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 license 资源位置。
|
||||
*
|
||||
* @param location license 资源位置
|
||||
*/
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
/**
|
||||
* License 校验通过后的关键信息。
|
||||
*
|
||||
* @param location license 位置
|
||||
* @param licenseId licenseId
|
||||
* @param keyId keyId
|
||||
* @param licenseType license 类型
|
||||
* @param expiresAt 过期时间
|
||||
*/
|
||||
public record EasyflowLicenseVerificationResult(String location,
|
||||
String licenseId,
|
||||
String keyId,
|
||||
String licenseType,
|
||||
String expiresAt) {
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.common.util.HashUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* EasyFlow license 校验器。
|
||||
*/
|
||||
public class EasyflowLicenseVerifier {
|
||||
|
||||
static final String LICENSE_FILE_PREFIX = "ELIC1.";
|
||||
static final String PRODUCT_NAME = "EasyFlow";
|
||||
static final String SIGNATURE_ALGORITHM = "Ed25519";
|
||||
static final String FINGERPRINT_ALGORITHM = "sha256(machineId|productUuid|sortedMacs)";
|
||||
static final String DEFAULT_PUBLIC_KEY_LOCATION = "classpath:easy-license-public.pem";
|
||||
|
||||
private static final Set<String> SUPPORTED_LICENSE_TYPES = Set.of("TRIAL", "STANDARD", "DEV");
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final MachineIdentityCollector machineIdentityCollector;
|
||||
private final Clock clock;
|
||||
private final String publicKeyLocation;
|
||||
|
||||
/**
|
||||
* 构造校验器。
|
||||
*
|
||||
* @param resourceLoader Spring 资源加载器
|
||||
* @param machineIdentityCollector 机器信息采集器
|
||||
*/
|
||||
public EasyflowLicenseVerifier(ResourceLoader resourceLoader, MachineIdentityCollector machineIdentityCollector) {
|
||||
this(resourceLoader, machineIdentityCollector, Clock.systemUTC(), DEFAULT_PUBLIC_KEY_LOCATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造校验器。
|
||||
*
|
||||
* @param resourceLoader Spring 资源加载器
|
||||
* @param machineIdentityCollector 机器信息采集器
|
||||
* @param clock 时间时钟
|
||||
* @param publicKeyLocation 公钥位置
|
||||
*/
|
||||
EasyflowLicenseVerifier(ResourceLoader resourceLoader,
|
||||
MachineIdentityCollector machineIdentityCollector,
|
||||
Clock clock,
|
||||
String publicKeyLocation) {
|
||||
this.resourceLoader = resourceLoader;
|
||||
this.machineIdentityCollector = machineIdentityCollector;
|
||||
this.clock = clock;
|
||||
this.publicKeyLocation = publicKeyLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验指定位置的 license。
|
||||
*
|
||||
* @param location license 位置
|
||||
* @return 校验结果
|
||||
*/
|
||||
public EasyflowLicenseVerificationResult verify(String location) {
|
||||
if (!StringUtils.hasText(location)) {
|
||||
throw EasyflowLicenseException.missing("未配置 easyflow.license.location", location);
|
||||
}
|
||||
|
||||
try {
|
||||
ResolvedLicenseResource resolved = resolveLicenseResource(location);
|
||||
String content = readText(resolved.resource());
|
||||
return verifyContent(resolved.actualLocation(), content);
|
||||
} catch (EasyflowLicenseException ex) {
|
||||
throw ex;
|
||||
} catch (IOException e) {
|
||||
throw EasyflowLicenseException.missing("lic 文件读取失败", location, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 license 文本内容。
|
||||
*
|
||||
* @param location license 位置
|
||||
* @param content license 文本
|
||||
* @return 校验结果
|
||||
*/
|
||||
EasyflowLicenseVerificationResult verifyContent(String location, String content) {
|
||||
String normalizedContent = normalizeRequired(content, "licenseContent", location, null, null).trim();
|
||||
if (!normalizedContent.startsWith(LICENSE_FILE_PREFIX)) {
|
||||
throw EasyflowLicenseException.invalid("license 前缀非法", location, null, null);
|
||||
}
|
||||
|
||||
JsonNode envelopeNode = decodeEnvelope(normalizedContent, location);
|
||||
String keyId = requireText(envelopeNode, "keyId", location, null, null);
|
||||
JsonNode payloadNode = requireNode(envelopeNode, "payload", location, null, keyId);
|
||||
String licenseId = requireText(payloadNode, "licenseId", location, null, keyId);
|
||||
|
||||
validateEnvelope(envelopeNode, location, licenseId, keyId);
|
||||
validatePayload(payloadNode, location, licenseId, keyId);
|
||||
verifySignature(payloadNode, requireText(envelopeNode, "signature", location, licenseId, keyId), location, licenseId, keyId);
|
||||
verifyFingerprint(payloadNode, location, licenseId, keyId);
|
||||
|
||||
String licenseType = requireText(payloadNode, "licenseType", location, licenseId, keyId);
|
||||
String expiresAt = payloadNode.path("expiresAt").isNull() ? null : payloadNode.path("expiresAt").asText(null);
|
||||
return new EasyflowLicenseVerificationResult(location, licenseId, keyId, licenseType, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建机器指纹材料。
|
||||
*
|
||||
* @param machineId machineId
|
||||
* @param productUuid productUuid
|
||||
* @param macAddresses MAC 地址文本
|
||||
* @return 指纹材料
|
||||
*/
|
||||
static String buildMachineFingerprintMaterial(String machineId, String productUuid, String macAddresses) {
|
||||
String normalizedMachineId = normalizeMachineId(machineId);
|
||||
String normalizedProductUuid = normalizeProductUuid(productUuid);
|
||||
List<String> normalizedMacs = normalizeMacAddresses(macAddresses);
|
||||
return "machineId=" + normalizedMachineId
|
||||
+ "|productUuid=" + normalizedProductUuid
|
||||
+ "|sortedMacs=" + String.join(",", normalizedMacs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算机器指纹。
|
||||
*
|
||||
* @param identity 机器信息
|
||||
* @return 机器指纹
|
||||
*/
|
||||
static String computeMachineFingerprint(MachineIdentity identity) {
|
||||
return HashUtil.sha256(buildMachineFingerprintMaterial(identity.machineId(), identity.productUuid(), identity.macAddresses()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 MAC 地址文本列表。
|
||||
*
|
||||
* @param macAddresses MAC 地址文本
|
||||
* @return 归一化后的 MAC 地址列表
|
||||
*/
|
||||
static List<String> normalizeMacAddresses(String macAddresses) {
|
||||
String normalized = normalizeRequired(macAddresses, "macAddresses", null, null, null);
|
||||
String[] parts = normalized.split("[,\\n]");
|
||||
LinkedHashSet<String> distinct = new LinkedHashSet<>();
|
||||
for (String part : parts) {
|
||||
if (StringUtils.hasText(part)) {
|
||||
distinct.add(normalizeMacAddress(part));
|
||||
}
|
||||
}
|
||||
if (distinct.isEmpty()) {
|
||||
throw new IllegalArgumentException("macAddresses 不能为空");
|
||||
}
|
||||
List<String> sorted = new ArrayList<>(distinct);
|
||||
sorted.sort(Comparator.naturalOrder());
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化单个 MAC 地址。
|
||||
*
|
||||
* @param macAddress MAC 地址
|
||||
* @return 归一化后的 MAC 地址
|
||||
*/
|
||||
static String normalizeMacAddress(String macAddress) {
|
||||
String compact = normalizeRequired(macAddress, "macAddress", null, null, null)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-f0-9]", "");
|
||||
if (compact.length() != 12) {
|
||||
throw new IllegalArgumentException("MAC 地址非法: " + macAddress);
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder(17);
|
||||
for (int index = 0; index < compact.length(); index += 2) {
|
||||
if (index > 0) {
|
||||
builder.append(':');
|
||||
}
|
||||
builder.append(compact, index, index + 2);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 machineId。
|
||||
*
|
||||
* @param machineId machineId
|
||||
* @return 归一化结果
|
||||
*/
|
||||
static String normalizeMachineId(String machineId) {
|
||||
return normalizeRequired(machineId, "machineId", null, null, null).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 productUuid。
|
||||
*
|
||||
* @param productUuid productUuid
|
||||
* @return 归一化结果
|
||||
*/
|
||||
static String normalizeProductUuid(String productUuid) {
|
||||
return normalizeRequired(productUuid, "productUuid", null, null, null).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private JsonNode decodeEnvelope(String content, String location) {
|
||||
try {
|
||||
String encoded = content.substring(LICENSE_FILE_PREFIX.length());
|
||||
byte[] decodedBytes = Base64.getUrlDecoder().decode(encoded);
|
||||
return OBJECT_MAPPER.readTree(decodedBytes);
|
||||
} catch (IllegalArgumentException | IOException e) {
|
||||
throw EasyflowLicenseException.invalid("license 内容解码失败", location, null, null, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEnvelope(JsonNode envelopeNode, String location, String licenseId, String keyId) {
|
||||
int formatVersion = envelopeNode.path("formatVersion").asInt(Integer.MIN_VALUE);
|
||||
if (formatVersion != 1) {
|
||||
throw EasyflowLicenseException.invalid("formatVersion 非法", location, licenseId, keyId);
|
||||
}
|
||||
String algorithm = requireText(envelopeNode, "alg", location, licenseId, keyId);
|
||||
if (!SIGNATURE_ALGORITHM.equals(algorithm)) {
|
||||
throw EasyflowLicenseException.invalid("签名算法非法", location, licenseId, keyId);
|
||||
}
|
||||
requireText(envelopeNode, "signature", location, licenseId, keyId);
|
||||
}
|
||||
|
||||
private void validatePayload(JsonNode payloadNode, String location, String licenseId, String keyId) {
|
||||
String product = requireText(payloadNode, "product", location, licenseId, keyId);
|
||||
if (!PRODUCT_NAME.equals(product)) {
|
||||
throw EasyflowLicenseException.invalid("product 非法", location, licenseId, keyId);
|
||||
}
|
||||
|
||||
String licenseType = requireText(payloadNode, "licenseType", location, licenseId, keyId);
|
||||
if (!SUPPORTED_LICENSE_TYPES.contains(licenseType)) {
|
||||
throw EasyflowLicenseException.invalid("licenseType 非法", location, licenseId, keyId);
|
||||
}
|
||||
|
||||
String fingerprintAlgorithm = requireText(payloadNode, "fingerprintAlgorithm", location, licenseId, keyId);
|
||||
if (!FINGERPRINT_ALGORITHM.equals(fingerprintAlgorithm)) {
|
||||
throw EasyflowLicenseException.invalid("fingerprintAlgorithm 非法", location, licenseId, keyId);
|
||||
}
|
||||
|
||||
requireText(payloadNode, "customerName", location, licenseId, keyId);
|
||||
requireText(payloadNode, "machineFingerprint", location, licenseId, keyId);
|
||||
|
||||
parseInstant(requireText(payloadNode, "issuedAt", location, licenseId, keyId), "issuedAt", location, licenseId, keyId);
|
||||
|
||||
JsonNode expiresAtNode = payloadNode.path("expiresAt");
|
||||
if ("DEV".equals(licenseType)) {
|
||||
if (!expiresAtNode.isMissingNode() && !expiresAtNode.isNull()) {
|
||||
throw EasyflowLicenseException.invalid("DEV license 不允许 expiresAt", location, licenseId, keyId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (expiresAtNode.isMissingNode() || expiresAtNode.isNull()) {
|
||||
throw EasyflowLicenseException.invalid("非 DEV license 必须提供 expiresAt", location, licenseId, keyId);
|
||||
}
|
||||
|
||||
Instant expiresAt = parseInstant(expiresAtNode.asText(), "expiresAt", location, licenseId, keyId);
|
||||
if (Instant.now(clock).isAfter(expiresAt)) {
|
||||
throw EasyflowLicenseException.invalid("license 已过期", location, licenseId, keyId);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifySignature(JsonNode payloadNode, String signatureValue, String location, String licenseId, String keyId) {
|
||||
try {
|
||||
PublicKey publicKey = loadPublicKey();
|
||||
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(LicenseCanonicalJsonSerializer.serialize(payloadNode).getBytes(StandardCharsets.UTF_8));
|
||||
boolean valid = signature.verify(Base64.getDecoder().decode(signatureValue));
|
||||
if (!valid) {
|
||||
throw EasyflowLicenseException.invalid("license 签名校验失败", location, licenseId, keyId);
|
||||
}
|
||||
} catch (EasyflowLicenseException ex) {
|
||||
throw ex;
|
||||
} catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
|
||||
throw EasyflowLicenseException.invalid("license 签名校验失败", location, licenseId, keyId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyFingerprint(JsonNode payloadNode, String location, String licenseId, String keyId) {
|
||||
String expectedFingerprint = requireText(payloadNode, "machineFingerprint", location, licenseId, keyId);
|
||||
MachineIdentity identity;
|
||||
try {
|
||||
identity = machineIdentityCollector.collect();
|
||||
} catch (Exception e) {
|
||||
throw EasyflowLicenseException.invalid("机器参数采集失败: " + e.getMessage(), location, licenseId, keyId, e);
|
||||
}
|
||||
|
||||
String actualFingerprint;
|
||||
try {
|
||||
actualFingerprint = computeMachineFingerprint(identity);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw EasyflowLicenseException.invalid("机器指纹计算失败: " + e.getMessage(), location, licenseId, keyId, e);
|
||||
}
|
||||
|
||||
if (!expectedFingerprint.equals(actualFingerprint)) {
|
||||
throw EasyflowLicenseException.invalid("license 机器指纹不匹配", location, licenseId, keyId);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey loadPublicKey() throws GeneralSecurityException, IOException {
|
||||
Resource publicKeyResource = resourceLoader.getResource(publicKeyLocation);
|
||||
if (!publicKeyResource.exists() || !publicKeyResource.isReadable()) {
|
||||
throw new IOException("公钥资源不存在或不可读: " + publicKeyLocation);
|
||||
}
|
||||
String pem = readText(publicKeyResource);
|
||||
String normalizedPem = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
byte[] keyBytes = Base64.getDecoder().decode(normalizedPem);
|
||||
return KeyFactory.getInstance(SIGNATURE_ALGORITHM).generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||
}
|
||||
|
||||
private String readText(Resource resource) throws IOException {
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
}
|
||||
|
||||
private ResolvedLicenseResource resolveLicenseResource(String location) {
|
||||
String normalizedLocation = location.trim();
|
||||
if (normalizedLocation.startsWith("file:")) {
|
||||
return resolveFileLicenseResource(normalizedLocation, normalizedLocation.substring("file:".length()));
|
||||
}
|
||||
|
||||
Resource resource = resourceLoader.getResource(normalizedLocation);
|
||||
if (!resource.exists() || !resource.isReadable()) {
|
||||
throw EasyflowLicenseException.missing("lic 文件不存在", normalizedLocation);
|
||||
}
|
||||
return new ResolvedLicenseResource(resource, normalizedLocation);
|
||||
}
|
||||
|
||||
private ResolvedLicenseResource resolveFileLicenseResource(String originalLocation, String rawPath) {
|
||||
Path candidatePath = Paths.get(rawPath).toAbsolutePath().normalize();
|
||||
|
||||
if (Files.isDirectory(candidatePath)) {
|
||||
return resolveSingleLicFile(candidatePath, originalLocation);
|
||||
}
|
||||
|
||||
if (Files.exists(candidatePath) && Files.isRegularFile(candidatePath) && Files.isReadable(candidatePath)) {
|
||||
return new ResolvedLicenseResource(resourceLoader.getResource(candidatePath.toUri().toString()), candidatePath.toUri().toString());
|
||||
}
|
||||
|
||||
if (candidatePath.getFileName() != null
|
||||
&& candidatePath.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".lic")) {
|
||||
Path parent = candidatePath.getParent();
|
||||
if (parent != null && Files.isDirectory(parent)) {
|
||||
return resolveSingleLicFile(parent, originalLocation);
|
||||
}
|
||||
}
|
||||
|
||||
throw EasyflowLicenseException.missing("lic 文件不存在", originalLocation);
|
||||
}
|
||||
|
||||
private ResolvedLicenseResource resolveSingleLicFile(Path directory, String originalLocation) {
|
||||
try (Stream<Path> stream = Files.list(directory)) {
|
||||
List<Path> licFiles = stream
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(Files::isReadable)
|
||||
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".lic"))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (licFiles.isEmpty()) {
|
||||
throw EasyflowLicenseException.missing("lic 文件不存在", originalLocation);
|
||||
}
|
||||
if (licFiles.size() > 1) {
|
||||
throw EasyflowLicenseException.missing("检测到多个 .lic 文件,无法确定应使用哪个文件", originalLocation);
|
||||
}
|
||||
|
||||
Path licFile = licFiles.get(0);
|
||||
return new ResolvedLicenseResource(resourceLoader.getResource(licFile.toUri().toString()), licFile.toUri().toString());
|
||||
} catch (IOException e) {
|
||||
throw EasyflowLicenseException.missing("lic 文件读取失败", originalLocation, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(JsonNode node, String field, String location, String licenseId, String keyId) {
|
||||
JsonNode value = node.path(field);
|
||||
if (value.isMissingNode() || value.isNull()) {
|
||||
throw EasyflowLicenseException.invalid(field + " 缺失", location, licenseId, keyId);
|
||||
}
|
||||
return normalizeRequired(value.asText(), field, location, licenseId, keyId);
|
||||
}
|
||||
|
||||
private static JsonNode requireNode(JsonNode node, String field, String location, String licenseId, String keyId) {
|
||||
JsonNode value = node.path(field);
|
||||
if (value.isMissingNode() || value.isNull()) {
|
||||
throw EasyflowLicenseException.invalid(field + " 缺失", location, licenseId, keyId);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Instant parseInstant(String value, String field, String location, String licenseId, String keyId) {
|
||||
try {
|
||||
return Instant.parse(value);
|
||||
} catch (Exception e) {
|
||||
throw EasyflowLicenseException.invalid(field + " 时间格式非法", location, licenseId, keyId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeRequired(String value, String field, String location, String licenseId, String keyId) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (!StringUtils.hasText(normalized)) {
|
||||
if (location != null || licenseId != null || keyId != null) {
|
||||
throw EasyflowLicenseException.invalid(field + " 不能为空", location, licenseId, keyId);
|
||||
}
|
||||
throw new IllegalArgumentException(field + " 不能为空");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已解析的 license 资源。
|
||||
*
|
||||
* @param resource Spring 资源
|
||||
* @param actualLocation 实际使用的位置
|
||||
*/
|
||||
private record ResolvedLicenseResource(Resource resource, String actualLocation) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* License payload 的 canonical JSON 序列化器。
|
||||
*/
|
||||
public final class LicenseCanonicalJsonSerializer {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private LicenseCanonicalJsonSerializer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 节点序列化为 canonical JSON 字符串。
|
||||
*
|
||||
* @param node JSON 节点
|
||||
* @return canonical JSON
|
||||
*/
|
||||
public static String serialize(JsonNode node) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(sortNode(node));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("无法序列化 canonical JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归排序 JSON 节点。
|
||||
*
|
||||
* @param node JSON 节点
|
||||
* @return 排序后的对象
|
||||
*/
|
||||
static Object sortNode(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
TreeMap<String, Object> sorted = new TreeMap<>();
|
||||
ObjectNode objectNode = (ObjectNode) node;
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = objectNode.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
sorted.put(entry.getKey(), sortNode(entry.getValue()));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
List<Object> items = new ArrayList<>();
|
||||
ArrayNode arrayNode = (ArrayNode) node;
|
||||
for (JsonNode item : arrayNode) {
|
||||
items.add(sortNode(item));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return node.textValue();
|
||||
}
|
||||
if (node.isBoolean()) {
|
||||
return node.booleanValue();
|
||||
}
|
||||
if (node.isIntegralNumber()) {
|
||||
return node.bigIntegerValue();
|
||||
}
|
||||
if (node.isFloatingPointNumber()) {
|
||||
return node.decimalValue();
|
||||
}
|
||||
return node.asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
/**
|
||||
* 当前机器的授权指纹输入信息。
|
||||
*
|
||||
* @param machineId 操作系统级机器标识
|
||||
* @param productUuid 硬件或固件级设备标识
|
||||
* @param macAddresses 网卡 MAC 地址文本
|
||||
*/
|
||||
public record MachineIdentity(String machineId, String productUuid, String macAddresses) {
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package tech.easyflow.autoconfig.license;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 采集当前机器的授权指纹输入信息。
|
||||
*/
|
||||
@Component
|
||||
public class MachineIdentityCollector {
|
||||
|
||||
private static final Duration COMMAND_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* 采集当前机器的授权标识信息。
|
||||
*
|
||||
* @return 当前机器标识
|
||||
*/
|
||||
public MachineIdentity collect() {
|
||||
String osName = currentOsName().toLowerCase(Locale.ROOT);
|
||||
if (osName.contains("win")) {
|
||||
return collectWindowsIdentity();
|
||||
}
|
||||
if (osName.contains("mac") || osName.contains("darwin")) {
|
||||
return collectMacIdentity();
|
||||
}
|
||||
if (osName.contains("nux") || osName.contains("linux")) {
|
||||
return collectLinuxIdentity();
|
||||
}
|
||||
throw new IllegalStateException("不支持的操作系统: " + currentOsName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前操作系统名称。
|
||||
*
|
||||
* @return 操作系统名称
|
||||
*/
|
||||
protected String currentOsName() {
|
||||
return System.getProperty("os.name", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集 Linux 机器信息。
|
||||
*
|
||||
* @return 机器信息
|
||||
*/
|
||||
protected MachineIdentity collectLinuxIdentity() {
|
||||
String machineId = executeShellCommand("读取 Linux machineId", "cat /etc/machine-id");
|
||||
String productUuid = executeShellCommand("读取 Linux productUuid", "cat /sys/class/dmi/id/product_uuid");
|
||||
String macAddress = executeShellCommand("读取 Linux 默认网卡 MAC",
|
||||
"cat /sys/class/net/$(ip route | awk '/default/ {print $5; exit}')/address");
|
||||
return new MachineIdentity(machineId, productUuid, macAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集 macOS 机器信息。
|
||||
*
|
||||
* @return 机器信息
|
||||
*/
|
||||
protected MachineIdentity collectMacIdentity() {
|
||||
String machineId = executeShellCommand("读取 macOS machineId",
|
||||
"ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/ {print $(NF-1)}'");
|
||||
String productUuid = executeShellCommand("读取 macOS productUuid",
|
||||
"ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/ {print $(NF-1)}'");
|
||||
String macAddress = executeShellCommand("读取 macOS 默认网卡 MAC",
|
||||
"networksetup -listallhardwareports | awk '/Device/ {device=$2} /Ethernet Address/ {print $3; exit}'");
|
||||
return new MachineIdentity(machineId, productUuid, macAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集 Windows 机器信息。
|
||||
*
|
||||
* @return 机器信息
|
||||
*/
|
||||
protected MachineIdentity collectWindowsIdentity() {
|
||||
String machineId = executePowerShellCommand("读取 Windows machineId",
|
||||
"(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid");
|
||||
String productUuid = executePowerShellCommand("读取 Windows productUuid",
|
||||
"(Get-CimInstance Win32_ComputerSystemProduct).UUID");
|
||||
String macAddress = executePowerShellCommand("读取 Windows 默认网卡 MAC",
|
||||
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up' -and $_.MacAddress} | Select-Object -First 1 -ExpandProperty MacAddress)");
|
||||
return new MachineIdentity(machineId, productUuid, macAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 shell 执行命令。
|
||||
*
|
||||
* @param description 描述
|
||||
* @param command 命令
|
||||
* @return 命令输出
|
||||
*/
|
||||
protected String executeShellCommand(String description, String command) {
|
||||
return executeCommand(description, List.of("sh", "-lc", command));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 PowerShell 执行命令。
|
||||
*
|
||||
* @param description 描述
|
||||
* @param command 命令
|
||||
* @return 命令输出
|
||||
*/
|
||||
protected String executePowerShellCommand(String description, String command) {
|
||||
return executeCommand(description, List.of("powershell", "-NoProfile", "-Command", command));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令并返回文本输出。
|
||||
*
|
||||
* @param description 描述
|
||||
* @param command 命令行
|
||||
* @return 命令输出
|
||||
*/
|
||||
protected String executeCommand(String description, List<String> command) {
|
||||
Process process = null;
|
||||
try {
|
||||
process = new ProcessBuilder(command)
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
boolean finished = process.waitFor(COMMAND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
throw new IllegalStateException(description + "超时");
|
||||
}
|
||||
|
||||
String output = readStream(process.getInputStream()).trim();
|
||||
if (process.exitValue() != 0) {
|
||||
throw new IllegalStateException(description + "失败,退出码=" + process.exitValue() + ",输出=" + output);
|
||||
}
|
||||
if (!StringUtils.hasText(output)) {
|
||||
throw new IllegalStateException(description + "失败,输出为空");
|
||||
}
|
||||
return output;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(description + "失败,无法执行命令", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(description + "失败,命令执行被中断", e);
|
||||
} finally {
|
||||
if (process != null) {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取输入流内容。
|
||||
*
|
||||
* @param inputStream 输入流
|
||||
* @return 文本内容
|
||||
* @throws IOException 读取失败
|
||||
*/
|
||||
protected String readStream(InputStream inputStream) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
inputStream.transferTo(outputStream);
|
||||
return outputStream.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user