feat: 增加 EasyFlow license 启动校验能力

- 新增 license 解析、验签、机器指纹校验与失败日志分流

- 支持开发态 classpath 读取与打包排除 lic 资源
This commit is contained in:
2026-05-10 17:07:24 +08:00
parent 516d43ce7d
commit ff863e3c27
16 changed files with 1654 additions and 0 deletions

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ build/
.jlsp/
.arts/
luceneKnowledge
**/*.lic
# v1
/easyflow-ui-react

View File

@@ -16,5 +16,11 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-web</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}
}

View File

@@ -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();
}
}

View File

@@ -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) {
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,478 @@
package tech.easyflow.autoconfig.license;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.DefaultResourceLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* {@link EasyflowLicenseVerifier} 测试。
*/
public class EasyflowLicenseVerifierTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-05-10T12:00:00Z"), ZoneOffset.UTC);
/**
* 合法 license 应校验通过。
*
* @throws Exception 测试失败
*/
@Test
public void shouldVerifyValidLicense() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
EasyflowLicenseVerificationResult result = verifier.verify(files.licenseLocation);
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
Assert.assertEquals("prod-2026-01", result.keyId());
Assert.assertEquals("STANDARD", result.licenseType());
}
/**
* 当配置目录时,应自动找到唯一的 .lic 文件。
*
* @throws Exception 测试失败
*/
@Test
public void shouldResolveSingleLicFileFromDirectory() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
EasyflowLicenseVerificationResult result = verifier.verify("file:" + Path.of(files.licensePath).getParent());
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
}
/**
* 当配置的固定文件不存在时,应回退到同目录唯一的 .lic 文件。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFallbackToSingleLicFileInSameDirectory() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
EasyflowLicenseVerificationResult result = verifier.verify("file:" + Path.of(files.licensePath).getParent().resolve("easyflow.lic"));
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
}
/**
* 缺失 license 文件时应抛出缺失异常。
*/
@Test
public void shouldFailWhenLicenseFileMissing() {
EasyflowLicenseVerifier verifier = createVerifier("classpath:missing-public.pem",
new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF"));
try {
verifier.verify("file:/path/not-exists.lic");
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertTrue(ex.isMissing());
Assert.assertTrue(ex.getMessage().contains("不存在"));
}
}
/**
* 同目录存在多个 .lic 文件时应报定位失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenMultipleLicFilesExist() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
Files.writeString(Path.of(files.licensePath).getParent().resolve("other.lic"), "dummy", StandardCharsets.UTF_8);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
try {
verifier.verify("file:" + Path.of(files.licensePath).getParent());
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertTrue(ex.isMissing());
Assert.assertTrue(ex.getMessage().contains("多个 .lic"));
}
}
/**
* 非法前缀应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenPrefixInvalid() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, true);
Files.writeString(Path.of(files.licensePath), "BAD." + Files.readString(Path.of(files.licensePath)), StandardCharsets.UTF_8);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
try {
verifier.verify(files.licenseLocation);
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertFalse(ex.isMissing());
Assert.assertTrue(ex.getMessage().contains("前缀"));
}
}
/**
* 非法 product 应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenProductInvalid() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "OtherFlow"), identity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
try {
verifier.verify(files.licenseLocation);
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertTrue(ex.getMessage().contains("product"));
}
}
/**
* 已过期的 STANDARD license 应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenLicenseExpired() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-05-01T00:00:00Z", "EasyFlow"), identity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
try {
verifier.verify(files.licenseLocation);
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertTrue(ex.getMessage().contains("过期"));
}
}
/**
* DEV license 可不包含 expiresAt。
*
* @throws Exception 测试失败
*/
@Test
public void shouldAllowDevLicenseWithoutExpiresAt() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "DEV", null, "EasyFlow"), identity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
EasyflowLicenseVerificationResult result = verifier.verify(files.licenseLocation);
Assert.assertNull(result.expiresAt());
}
/**
* 机器指纹不匹配时应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenFingerprintMismatch() throws Exception {
MachineIdentity licenseIdentity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
MachineIdentity runtimeIdentity = new MachineIdentity("machine-two", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(licenseIdentity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), licenseIdentity, false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, runtimeIdentity);
try {
verifier.verify(files.licenseLocation);
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertTrue(ex.getMessage().contains("指纹不匹配"));
}
}
/**
* classpath 资源应可被正确读取并校验。
*
* @throws Exception 测试失败
*/
@Test
public void shouldVerifyClasspathLicense() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
TestLicenseFiles files = createLicenseFiles(createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow"), identity, false);
writeClasspathTestResource("test-license/public.pem", Files.readString(Path.of(files.publicKeyPath), StandardCharsets.UTF_8));
writeClasspathTestResource("test-license/license.lic", Files.readString(Path.of(files.licensePath), StandardCharsets.UTF_8));
EasyflowLicenseVerifier verifier = createVerifier("classpath:test-license/public.pem", identity);
EasyflowLicenseVerificationResult result = verifier.verify("classpath:test-license/license.lic");
Assert.assertEquals("LIC-20260510-120000", result.licenseId());
}
/**
* formatVersion 非 1 时应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenFormatVersionInvalid() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 2, "Ed25519", payload.get("fingerprintAlgorithm").toString(), false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
assertInvalid(verifier, files.licenseLocation, "formatVersion");
}
/**
* alg 非 Ed25519 时应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenAlgorithmInvalid() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 1, "RSA", payload.get("fingerprintAlgorithm").toString(), false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
assertInvalid(verifier, files.licenseLocation, "签名算法");
}
/**
* fingerprintAlgorithm 不匹配时应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenFingerprintAlgorithmInvalid() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 1, "Ed25519", "sha256(other)", false);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
assertInvalid(verifier, files.licenseLocation, "fingerprintAlgorithm");
}
/**
* 篡改签名后应校验失败。
*
* @throws Exception 测试失败
*/
@Test
public void shouldFailWhenSignatureTampered() throws Exception {
MachineIdentity identity = new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF");
Map<String, Object> payload = createPayload(identity, "STANDARD", "2026-12-31T00:00:00Z", "EasyFlow");
TestLicenseFiles files = createLicenseFiles(payload, identity, false, 1, "Ed25519", payload.get("fingerprintAlgorithm").toString(), true);
EasyflowLicenseVerifier verifier = createVerifier(files.publicKeyLocation, identity);
assertInvalid(verifier, files.licenseLocation, "签名校验失败");
}
/**
* MAC 地址应完成归一化、去重与排序。
*/
@Test
public void shouldNormalizeMacAddresses() {
Assert.assertEquals("aa:bb:cc:dd:ee:ff",
EasyflowLicenseVerifier.normalizeMacAddress("AA-BB-CC-DD-EE-FF"));
Assert.assertEquals(
java.util.List.of("11:22:33:44:55:66", "aa:bb:cc:dd:ee:ff"),
EasyflowLicenseVerifier.normalizeMacAddresses("AA-BB-CC-DD-EE-FF\n11:22:33:44:55:66\naa:bb:cc:dd:ee:ff"));
}
/**
* 启动前校验器应在缺失 license 时直接失败。
*/
@Test
public void shouldFailBootstrapValidatorWhenLicenseMissing() {
EasyflowLicenseBootstrapValidator validator = new EasyflowLicenseBootstrapValidator(
createVerifier("classpath:missing-public.pem",
new MachineIdentity("machine-one", "product-one", "AA-BB-CC-DD-EE-FF"))
);
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(
new MapPropertySource("test", Map.of("easyflow.license.location", "file:/path/not-exists.lic"))
);
validator.setEnvironment(environment);
try {
validator.postProcessBeanFactory(null);
Assert.fail("预期应抛出异常");
} catch (IllegalStateException ex) {
Assert.assertTrue(ex.getMessage().contains("不存在") || ex.getMessage().contains("未配置"));
}
}
private EasyflowLicenseVerifier createVerifier(String publicKeyLocation, MachineIdentity identity) {
return new EasyflowLicenseVerifier(
new DefaultResourceLoader(),
new StubMachineIdentityCollector(identity),
FIXED_CLOCK,
publicKeyLocation
);
}
private TestLicenseFiles createLicenseFiles(Map<String, Object> payload,
MachineIdentity identity,
boolean keepOriginalPrefix) throws Exception {
return createLicenseFiles(payload, identity, keepOriginalPrefix, 1, "Ed25519",
payload.get("fingerprintAlgorithm").toString(), false);
}
private TestLicenseFiles createLicenseFiles(Map<String, Object> payload,
MachineIdentity identity,
boolean keepOriginalPrefix,
int formatVersion,
String algorithm,
String fingerprintAlgorithm,
boolean tamperSignature) throws Exception {
KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
payload.put("fingerprintAlgorithm", fingerprintAlgorithm);
String canonicalPayload = buildCanonicalPayloadJson(payload);
String signature = sign(canonicalPayload, keyPair);
if (tamperSignature) {
signature = Base64.getEncoder().encodeToString("tampered-signature".getBytes(StandardCharsets.UTF_8));
}
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("formatVersion", formatVersion);
envelope.put("alg", algorithm);
envelope.put("keyId", "prod-2026-01");
envelope.put("payload", payload);
envelope.put("signature", signature);
String licenseContent = EasyflowLicenseVerifier.LICENSE_FILE_PREFIX
+ Base64.getUrlEncoder().withoutPadding()
.encodeToString(OBJECT_MAPPER.writeValueAsBytes(envelope));
if (!keepOriginalPrefix) {
licenseContent = licenseContent.trim();
}
Path tempDir = Files.createTempDirectory("easyflow-license-test");
Path publicKeyPath = tempDir.resolve("public.pem");
Path licensePath = tempDir.resolve("license.lic");
Files.writeString(publicKeyPath, toPublicPem(keyPair), StandardCharsets.UTF_8);
Files.writeString(licensePath, licenseContent, StandardCharsets.UTF_8);
return new TestLicenseFiles(publicKeyPath.toUri().toString(),
licensePath.toUri().toString(),
publicKeyPath.toString(),
licensePath.toString());
}
private Map<String, Object> createPayload(MachineIdentity identity,
String licenseType,
String expiresAt,
String product) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("licenseId", "LIC-20260510-120000");
payload.put("product", product);
payload.put("customerName", "Test Customer");
payload.put("environment", "PROD");
payload.put("licenseType", licenseType);
payload.put("issuedAt", "2026-05-10T12:00:00Z");
payload.put("expiresAt", expiresAt);
payload.put("fingerprintAlgorithm", EasyflowLicenseVerifier.FINGERPRINT_ALGORITHM);
payload.put("machineFingerprint", EasyflowLicenseVerifier.computeMachineFingerprint(identity));
return payload;
}
private String buildCanonicalPayloadJson(Map<String, Object> payload) {
return "{"
+ "\"customerName\":\"" + payload.get("customerName") + "\","
+ "\"environment\":\"" + payload.get("environment") + "\","
+ "\"expiresAt\":" + quoteOrNull(payload.get("expiresAt")) + ","
+ "\"fingerprintAlgorithm\":\"" + payload.get("fingerprintAlgorithm") + "\","
+ "\"issuedAt\":\"" + payload.get("issuedAt") + "\","
+ "\"licenseId\":\"" + payload.get("licenseId") + "\","
+ "\"licenseType\":\"" + payload.get("licenseType") + "\","
+ "\"machineFingerprint\":\"" + payload.get("machineFingerprint") + "\","
+ "\"product\":\"" + payload.get("product") + "\""
+ "}";
}
private String quoteOrNull(Object value) {
return value == null ? "null" : "\"" + value + "\"";
}
private String sign(String canonicalPayload, KeyPair keyPair) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(keyPair.getPrivate());
signer.update(canonicalPayload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signer.sign());
}
private String toPublicPem(KeyPair keyPair) {
String base64 = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8))
.encodeToString(keyPair.getPublic().getEncoded());
return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----\n";
}
private void assertInvalid(EasyflowLicenseVerifier verifier, String location, String expectedMessagePart) {
try {
verifier.verify(location);
Assert.fail("预期应抛出异常");
} catch (EasyflowLicenseException ex) {
Assert.assertTrue(ex.getMessage().contains(expectedMessagePart));
}
}
private void writeClasspathTestResource(String relativePath, String content) throws Exception {
Path path = Path.of("target/test-classes").resolve(relativePath);
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
}
/**
* 测试用固定机器信息采集器。
*/
private static class StubMachineIdentityCollector extends MachineIdentityCollector {
private final MachineIdentity identity;
private StubMachineIdentityCollector(MachineIdentity identity) {
this.identity = identity;
}
/**
* 直接返回固定机器信息。
*
* @return 固定机器信息
*/
@Override
public MachineIdentity collect() {
return identity;
}
}
/**
* 测试临时文件信息。
*
* @param publicKeyLocation 公钥位置
* @param licenseLocation license 位置
* @param licensePath license 文件路径
*/
private record TestLicenseFiles(String publicKeyLocation,
String licenseLocation,
String publicKeyPath,
String licensePath) {
}
}

View File

@@ -0,0 +1,26 @@
package tech.easyflow.autoconfig.license;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
/**
* {@link LicenseCanonicalJsonSerializer} 测试。
*/
public class LicenseCanonicalJsonSerializerTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 应按 key 递归排序对象,且保持数组顺序。
*
* @throws Exception 解析失败
*/
@Test
public void shouldSerializeCanonicalJson() throws Exception {
JsonNode jsonNode = OBJECT_MAPPER.readTree("{\"b\":1,\"arr\":[{\"b\":2,\"a\":1},3],\"a\":{\"d\":4,\"c\":3}}");
String canonicalJson = LicenseCanonicalJsonSerializer.serialize(jsonNode);
Assert.assertEquals("{\"a\":{\"c\":3,\"d\":4},\"arr\":[{\"a\":1,\"b\":2},3],\"b\":1}", canonicalJson);
}
}

View File

@@ -0,0 +1,136 @@
package tech.easyflow.autoconfig.license;
import org.junit.Assert;
import org.junit.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* {@link MachineIdentityCollector} 测试。
*/
public class MachineIdentityCollectorTest {
/**
* Linux 采集命令应按约定返回三项机器参数。
*/
@Test
public void shouldCollectLinuxIdentity() {
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Linux");
collector.shellOutputs.put("cat /etc/machine-id", "linux-machine");
collector.shellOutputs.put("cat /sys/class/dmi/id/product_uuid", "linux-product");
collector.shellOutputs.put("cat /sys/class/net/$(ip route | awk '/default/ {print $5; exit}')/address", "aa:bb:cc:dd:ee:ff");
MachineIdentity identity = collector.collect();
Assert.assertEquals("linux-machine", identity.machineId());
Assert.assertEquals("linux-product", identity.productUuid());
Assert.assertEquals("aa:bb:cc:dd:ee:ff", identity.macAddresses());
}
/**
* macOS 采集命令应按约定返回三项机器参数。
*/
@Test
public void shouldCollectMacIdentity() {
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Mac OS X");
collector.shellOutputs.put("ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/ {print $(NF-1)}'", "mac-platform-uuid");
collector.shellOutputs.put("networksetup -listallhardwareports | awk '/Device/ {device=$2} /Ethernet Address/ {print $3; exit}'", "AA-BB-CC-DD-EE-FF");
MachineIdentity identity = collector.collect();
Assert.assertEquals("mac-platform-uuid", identity.machineId());
Assert.assertEquals("mac-platform-uuid", identity.productUuid());
Assert.assertEquals("AA-BB-CC-DD-EE-FF", identity.macAddresses());
}
/**
* Windows 采集命令应按约定返回三项机器参数。
*/
@Test
public void shouldCollectWindowsIdentity() {
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Windows 11");
collector.powerShellOutputs.put("(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid", "windows-machine");
collector.powerShellOutputs.put("(Get-CimInstance Win32_ComputerSystemProduct).UUID", "windows-product");
collector.powerShellOutputs.put("(Get-NetAdapter | Where-Object {$_.Status -eq 'Up' -and $_.MacAddress} | Select-Object -First 1 -ExpandProperty MacAddress)", "AA-BB-CC-DD-EE-FF");
MachineIdentity identity = collector.collect();
Assert.assertEquals("windows-machine", identity.machineId());
Assert.assertEquals("windows-product", identity.productUuid());
Assert.assertEquals("AA-BB-CC-DD-EE-FF", identity.macAddresses());
}
/**
* 空输出应被识别为采集失败。
*/
@Test
public void shouldFailWhenCommandOutputIsBlank() {
TestMachineIdentityCollector collector = new TestMachineIdentityCollector("Linux");
collector.shellOutputs.put("cat /etc/machine-id", "");
try {
collector.collect();
Assert.fail("预期应抛出异常");
} catch (IllegalStateException ex) {
Assert.assertTrue(ex.getMessage().contains("输出为空"));
}
}
/**
* 测试用机器信息采集器。
*/
private static class TestMachineIdentityCollector extends MachineIdentityCollector {
private final String osName;
private final Map<String, String> shellOutputs = new HashMap<>();
private final Map<String, String> powerShellOutputs = new HashMap<>();
private TestMachineIdentityCollector(String osName) {
this.osName = osName;
}
/**
* 返回固定的操作系统名称。
*
* @return 操作系统名称
*/
@Override
protected String currentOsName() {
return osName;
}
/**
* 返回预设 shell 输出。
*
* @param description 描述
* @param command 命令
* @return 命令输出
*/
@Override
protected String executeShellCommand(String description, String command) {
return lookupOutput(description, command, shellOutputs);
}
/**
* 返回预设 PowerShell 输出。
*
* @param description 描述
* @param command 命令
* @return 命令输出
*/
@Override
protected String executePowerShellCommand(String description, String command) {
return lookupOutput(description, command, powerShellOutputs);
}
private String lookupOutput(String description, String command, Map<String, String> outputs) {
if (!outputs.containsKey(command)) {
throw new IllegalStateException(description + "失败,命令未配置输出");
}
String output = outputs.get(command);
if (output == null || output.trim().isEmpty()) {
throw new IllegalStateException(description + "失败,输出为空");
}
return output;
}
}
}

View File

@@ -88,6 +88,16 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<excludes>
<exclude>**/*.lic</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>

View File

@@ -76,6 +76,8 @@ spring:
enabled: true
easyflow:
license:
location: classpath:easyflow.lic
chat:
# SSE 超时时间(毫秒),默认 10 分钟,可按需调整
sse-timeout-ms: 600000

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAg4rl3LjLI1Hc4CGsJCrZcieEp9gdWwHAUyDNoFjjts8=
-----END PUBLIC KEY-----