diff --git a/.gitignore b/.gitignore index 3e4edcd..ebd4761 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ build/ .jlsp/ .arts/ luceneKnowledge +**/*.lic # v1 /easyflow-ui-react \ No newline at end of file diff --git a/easyflow-modules/easyflow-module-autoconfig/pom.xml b/easyflow-modules/easyflow-module-autoconfig/pom.xml index 002cde0..d73bc4b 100644 --- a/easyflow-modules/easyflow-module-autoconfig/pom.xml +++ b/easyflow-modules/easyflow-module-autoconfig/pom.xml @@ -16,5 +16,11 @@ tech.easyflow easyflow-common-web + + junit + junit + ${junit.version} + test + diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseBootstrapValidator.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseBootstrapValidator.java new file mode 100644 index 0000000..2f6403d --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseBootstrapValidator.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseException.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseException.java new file mode 100644 index 0000000..cdbe737 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseException.java @@ -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 + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseProperties.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseProperties.java new file mode 100644 index 0000000..056d96c --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseProperties.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerificationResult.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerificationResult.java new file mode 100644 index 0000000..1f92925 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerificationResult.java @@ -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) { +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifier.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifier.java new file mode 100644 index 0000000..9e95ee6 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifier.java @@ -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 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 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 normalizeMacAddresses(String macAddresses) { + String normalized = normalizeRequired(macAddresses, "macAddresses", null, null, null); + String[] parts = normalized.split("[,\\n]"); + LinkedHashSet distinct = new LinkedHashSet<>(); + for (String part : parts) { + if (StringUtils.hasText(part)) { + distinct.add(normalizeMacAddress(part)); + } + } + if (distinct.isEmpty()) { + throw new IllegalArgumentException("macAddresses 不能为空"); + } + List 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 stream = Files.list(directory)) { + List 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) { + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializer.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializer.java new file mode 100644 index 0000000..be332d6 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializer.java @@ -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 sorted = new TreeMap<>(); + ObjectNode objectNode = (ObjectNode) node; + Iterator> fields = objectNode.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + sorted.put(entry.getKey(), sortNode(entry.getValue())); + } + return sorted; + } + if (node.isArray()) { + List 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(); + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/MachineIdentity.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/MachineIdentity.java new file mode 100644 index 0000000..eb3bfe4 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/MachineIdentity.java @@ -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) { +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/MachineIdentityCollector.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/MachineIdentityCollector.java new file mode 100644 index 0000000..8eebd54 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/MachineIdentityCollector.java @@ -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 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); + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifierTest.java b/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifierTest.java new file mode 100644 index 0000000..c1f82aa --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifierTest.java @@ -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 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 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 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 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 payload, + MachineIdentity identity, + boolean keepOriginalPrefix) throws Exception { + return createLicenseFiles(payload, identity, keepOriginalPrefix, 1, "Ed25519", + payload.get("fingerprintAlgorithm").toString(), false); + } + + private TestLicenseFiles createLicenseFiles(Map 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 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 createPayload(MachineIdentity identity, + String licenseType, + String expiresAt, + String product) { + Map 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 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) { + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializerTest.java b/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializerTest.java new file mode 100644 index 0000000..87252c3 --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializerTest.java @@ -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); + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/MachineIdentityCollectorTest.java b/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/MachineIdentityCollectorTest.java new file mode 100644 index 0000000..a97f85d --- /dev/null +++ b/easyflow-modules/easyflow-module-autoconfig/src/test/java/tech/easyflow/autoconfig/license/MachineIdentityCollectorTest.java @@ -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 shellOutputs = new HashMap<>(); + private final Map 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 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; + } + } +} diff --git a/easyflow-starter/easyflow-starter-all/pom.xml b/easyflow-starter/easyflow-starter-all/pom.xml index 95421d1..1b6cea3 100644 --- a/easyflow-starter/easyflow-starter-all/pom.xml +++ b/easyflow-starter/easyflow-starter-all/pom.xml @@ -88,6 +88,16 @@ + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + **/*.lic + + + org.springframework.boot spring-boot-maven-plugin diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml index ae65751..069e419 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml @@ -76,6 +76,8 @@ spring: enabled: true easyflow: + license: + location: classpath:easyflow.lic chat: # SSE 超时时间(毫秒),默认 10 分钟,可按需调整 sse-timeout-ms: 600000 diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/easy-license-public.pem b/easyflow-starter/easyflow-starter-all/src/main/resources/easy-license-public.pem new file mode 100644 index 0000000..c9aaff8 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/easy-license-public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAg4rl3LjLI1Hc4CGsJCrZcieEp9gdWwHAUyDNoFjjts8= +-----END PUBLIC KEY-----