SpringBoot项目实战:5分钟搞定License授权验证(附完整代码)
SpringBoot项目实战:5分钟搞定License授权验证(附完整代码)
最近在给一个商业项目做技术选型时,客户明确提出了一个要求:软件必须支持授权许可机制。他们不希望自己的产品被随意复制和分发,需要一个轻量级、易集成、且能快速上线的解决方案。说实话,一开始我有点头疼,因为传统的License实现要么过于复杂,需要引入重量级的第三方服务,要么安全性堪忧,容易被破解。
经过一番调研和踩坑,我发现基于TrueLicense库在 SpringBoot 项目中实现授权验证,其实可以非常优雅和高效。今天,我就把自己趟出来的这条“捷径”分享给你,目标是让你在五分钟内,理解核心流程并跑通一个可用的Demo。我们不会深究密码学的复杂原理,而是聚焦于“怎么用起来”,并提供可以直接复制粘贴的代码片段。
这篇文章适合那些正在开发需要商业化部署的SpringBoot应用,希望快速集成一套可靠授权机制的开发者。无论你是独立开发者、小团队的技术负责人,还是需要为产品增加许可功能的全栈工程师,接下来的内容都将为你提供一个清晰的、可落地的实战指南。
1. 理解核心:TrueLicense 与 SpringBoot 的轻量级融合
在开始敲代码之前,我们得先搞清楚我们要用的“武器”是什么,以及它如何与SpringBoot协同工作。TrueLicense是一个基于Java的、用于生成和验证许可证(License)的开源库。它的核心思想是利用非对称加密(RSA)来保证许可证文件的完整性和合法性。
简单来说,整个过程分为两方:
- 授权方(你):持有私钥,用于生成加密的
.lic许可证文件。这个文件里包含了授权信息,比如过期时间、允许的机器特征等。 - 使用方(客户):持有公钥,用于在启动或运行时验证你颁发的
.lic文件是否有效、是否被篡改。
在SpringBoot项目中集成,我们的目标就是搭建一个客户端验证模块。这个模块在应用启动时,自动检查指定位置的许可证文件,并验证其有效性。如果验证失败,则阻止应用正常启动或运行核心功能。
注意:TrueLicense 库本身只提供了基础的加密、解密和有效期验证框架。要实现诸如“绑定特定服务器硬件(MAC地址、CPU序列号)”等高级功能,我们需要对其进行扩展。这也是本次实战的重点和难点所在。
为了让整个架构更清晰,我们先通过一个表格来对比传统实现与我们即将采用的轻量级方案的差异:
| 对比维度 | 传统/复杂方案 | 本文轻量级方案 (TrueLicense + SpringBoot) |
|---|---|---|
| 核心依赖 | 可能需要自研全套加密、验证逻辑,或集成商业SDK | 引入一个轻量的truelicense-core依赖 |
| 集成复杂度 | 高,需要处理密钥管理、文件格式、验证拦截等多个模块 | 低,主要工作是配置和扩展几个关键类 |
| 安全性 | 取决于实现,良莠不齐 | 基于RSA非对称加密,具备较高的基础安全性 |
| 扩展性 | 灵活,但需要从头设计 | 良好,通过继承和重写可以方便地添加自定义校验规则 |
| 上手速度 | 慢 | 快(本文目标:5分钟理解核心) |
| 适用场景 | 对授权有极度定制化需求的大型商业系统 | 中小型商业软件、SaaS服务、需要快速实现授权功能的Java/SpringBoot项目 |
理解了这套组合拳的优势,我们就可以开始动手了。接下来,我们从最基础的工程搭建和依赖配置开始。
2. 五分钟快速启动:基础环境与配置
所谓“工欲善其事,必先利其器”。我们先花两分钟把项目骨架和必要的依赖搭建好。这里假设你已经有一个现成的SpringBoot项目,或者可以快速通过 Spring Initializr 生成一个。
2.1 引入核心依赖
首先,打开你的pom.xml文件,在<dependencies>部分添加 TrueLicense 的核心库。这里有一个关键点:为了避免将 TrueLicense 的依赖传递到你的最终产品包中(出于安全考虑,私钥信息不应存在于客户端),我们将其作用域设置为provided。
<dependency> <groupId>de.schlichtherle.truelicense</groupId> <artifactId>truelicense-core</artifactId> <version>1.33</version> <scope>provided</scope> </dependency>scope设置为provided意味着这个依赖在编译和测试时需要,但在打包成可执行的JAR或WAR文件时不会被包含进去。你需要在部署客户环境时,确保客户服务器的类路径下存在这个JAR包(例如放在Tomcat/lib目录下),或者采用其他方式提供。这是保护私钥安全的一种常见做法。
2.2 生成密钥对:服务端的准备工作
在你可以为客户生成许可证之前,必须先拥有一对RSA密钥(公钥和私钥)。这一步通常在你的开发环境或安全的发布服务器上执行一次,生成的私钥必须严格保密,公钥则交给客户端验证模块使用。
我们使用Java自带的keytool工具来完成这个任务。打开你的终端(Windows CMD 或 Linux/Mac Terminal),依次执行以下命令:
# 1. 生成私钥库(privateKeys.store),别名设为 `privateKey`,有效期3650天(约10年) keytool -genkeypair -alias privateKey -keyalg RSA -keysize 2048 -validity 3650 -keystore privateKeys.store -storepass 123456 -keypass 123456 -dname "CN=YourCompany, OU=RD, O=YourOrg, L=City, ST=Province, C=CN" # 2. 从私钥库中导出证书文件(certfile.cer) keytool -exportcert -alias privateKey -keystore privateKeys.store -storepass 123456 -file certfile.cer # 3. 将证书导入到公钥库(publicCerts.store),别名设为 `publicCert` keytool -importcert -alias publicCert -keystore publicCerts.store -storepass 123456 -file certfile.cer -noprompt执行成功后,你会得到三个文件:
privateKeys.store:私钥库,绝对保密!用于在服务端生成License文件。certfile.cer:中间证书文件,可临时使用,导入后可删除。publicCerts.store:公钥库,分发给客户端。用于在客户端验证License文件。
请务必将privateKeys.store妥善保管,并记住你设置的storepass(库密码)和keypass(密钥密码)。在实际生产环境中,密码应使用强密码,并考虑使用硬件安全模块(HSM)等更安全的方式存储私钥。
2.3 客户端基础配置
在客户端SpringBoot项目中,我们需要配置License验证所需的基本参数。通常,我会将这些信息放在application.yml或application.properties中。
# application.yml license: subject: my-springboot-app # 证书主题,与生成License时保持一致 public-alias: publicCert # 公钥别名 store-pass: 123456 # 公钥库密码 license-path: classpath:license.lic # License文件存放路径,可改为外部路径如 /opt/app/license.lic public-keys-store-path: classpath:/license/publicCerts.store # 公钥库文件路径这里license-path配置为classpath:license.lic,意味着我们把许可证文件放在项目的资源目录下,这适用于演示。在实际部署中,强烈建议使用绝对路径(如/opt/your-app/license.lic),这样可以在不重新打包应用的情况下,单独替换许可证文件。
3. 核心实现:自定义验证与自动装配
基础配置完成后,我们进入最核心的部分:编写代码来实现许可证的安装和验证。TrueLicense 提供了骨架,但我们需要“填充血肉”,特别是加入对服务器硬件的绑定验证。
3.1 定义自定义的许可校验模型
我们需要创建一个模型类,用来定义除了有效期之外,我们还希望校验哪些信息。比如,绑定服务器的IP、MAC地址、CPU序列号等。
package com.yourcompany.license.model; import lombok.Data; import java.io.Serializable; import java.util.List; /** * 自定义许可证校验参数模型 * 这个对象的内容会被加密后存入License文件 */ @Data public class LicenseCheckModel implements Serializable { private static final long serialVersionUID = 1L; /** * 可被允许的IP地址列表 */ private List<String> ipAddress; /** * 可被允许的MAC地址列表 */ private List<String> macAddress; /** * 可被允许的CPU序列号 */ private String cpuSerial; /** * 可被允许的主板序列号 */ private String mainBoardSerial; // 可以根据需要增加其他字段,如项目名称、授权模块等 // private String projectName; // private List<String> authorizedModules; }使用@Data注解(Lombok)可以省去getter/setter的编写。确保你的项目引入了Lombok依赖。
3.2 获取服务器硬件信息
为了进行绑定,我们需要有能力获取到运行应用的服务器的硬件信息。我们设计一个抽象类AbstractServerInfos来定义获取这些信息的接口,然后为不同的操作系统(Windows/Linux)提供具体实现。
抽象类:AbstractServerInfos
package com.yourcompany.license.service; import com.yourcompany.license.model.LicenseCheckModel; import lombok.extern.slf4j.Slf4j; import java.net.InetAddress; import java.net.NetworkInterface; import java.util.*; @Slf4j public abstract class AbstractServerInfos { /** * 组装当前服务器的硬件信息 */ public LicenseCheckModel getServerInfos() { LicenseCheckModel result = new LicenseCheckModel(); try { result.setIpAddress(this.getIpAddress()); result.setMacAddress(this.getMacAddress()); result.setCpuSerial(this.getCpuSerial()); result.setMainBoardSerial(this.getMainBoardSerial()); } catch (Exception e) { log.error("获取服务器硬件信息失败", e); } return result; } // 以下是需要子类实现的抽象方法 protected abstract List<String> getIpAddress() throws Exception; protected abstract List<String> getMacAddress() throws Exception; protected abstract String getCpuSerial() throws Exception; protected abstract String getMainBoardSerial() throws Exception; // 通用工具方法:获取所有符合条件的网络接口 protected List<InetAddress> getLocalAllInetAddress() throws Exception { List<InetAddress> result = new ArrayList<>(); Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces(); while (networkInterfaces.hasMoreElements()) { NetworkInterface ni = networkInterfaces.nextElement(); Enumeration<InetAddress> addresses = ni.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress addr = addresses.nextElement(); // 过滤掉回环地址、链路本地地址、组播地址等 if (!addr.isLoopbackAddress() && !addr.isLinkLocalAddress() && !addr.isMulticastAddress()) { result.add(addr); } } } return result; } // 通用工具方法:通过InetAddress获取MAC地址 protected String getMacByInetAddress(InetAddress inetAddress) { try { byte[] mac = NetworkInterface.getByInetAddress(inetAddress).getHardwareAddress(); if (mac == null) return null; StringBuilder sb = new StringBuilder(); for (int i = 0; i < mac.length; i++) { sb.append(String.format("%02X%s", mac[i], (i < mac.length - 1) ? "-" : "")); } return sb.toString(); } catch (Exception e) { log.error("获取MAC地址失败", e); } return null; } }Linux系统实现:LinuxServerInfos
package com.yourcompany.license.service; import org.apache.commons.lang3.StringUtils; import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.List; import java.util.stream.Collectors; public class LinuxServerInfos extends AbstractServerInfos { @Override protected List<String> getIpAddress() throws Exception { List<String> result = getLocalAllInetAddress().stream() .map(InetAddress::getHostAddress) .distinct() .collect(Collectors.toList()); return result; } @Override protected List<String> getMacAddress() throws Exception { List<String> result = getLocalAllInetAddress().stream() .map(this::getMacByInetAddress) .filter(StringUtils::isNotBlank) .distinct() .collect(Collectors.toList()); return result; } @Override protected String getCpuSerial() throws Exception { // 使用 dmidecode 命令获取CPU ID,需要root权限 String[] shell = {"/bin/bash", "-c", "dmidecode -t processor | grep 'ID' | awk -F ': ' '{print $2}' | head -n 1"}; Process process = Runtime.getRuntime().exec(shell); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return StringUtils.isNotBlank(line) ? line.trim() : ""; } } @Override protected String getMainBoardSerial() throws Exception { // 使用 dmidecode 命令获取主板序列号,需要root权限 String[] shell = {"/bin/bash", "-c", "dmidecode -t baseboard | grep 'Serial Number' | awk -F ': ' '{print $2}' | head -n 1"}; Process process = Runtime.getRuntime().exec(shell); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return StringUtils.isNotBlank(line) ? line.trim() : ""; } } }Windows系统实现:WindowsServerInfos
package com.yourcompany.license.service; import java.util.List; import java.util.Scanner; import java.util.stream.Collectors; public class WindowsServerInfos extends AbstractServerInfos { @Override protected List<String> getIpAddress() throws Exception { // 复用父类通用方法 List<String> result = getLocalAllInetAddress().stream() .map(InetAddress::getHostAddress) .distinct() .collect(Collectors.toList()); return result; } @Override protected List<String> getMacAddress() throws Exception { // 复用父类通用方法 List<String> result = getLocalAllInetAddress().stream() .map(this::getMacByInetAddress) .filter(mac -> mac != null && !mac.isEmpty()) .distinct() .collect(Collectors.toList()); return result; } @Override protected String getCpuSerial() throws Exception { // 使用 wmic 命令获取 CPU ProcessorID Process process = Runtime.getRuntime().exec("wmic cpu get processorid"); try (Scanner scanner = new Scanner(process.getInputStream())) { while (scanner.hasNext()) { String next = scanner.next(); if ("ProcessorId".equalsIgnoreCase(next)) { continue; } if (scanner.hasNext()) { return scanner.next().trim(); } } } return ""; } @Override protected String getMainBoardSerial() throws Exception { // 使用 wmic 命令获取主板序列号 Process process = Runtime.getRuntime().exec("wmic baseboard get serialnumber"); try (Scanner scanner = new Scanner(process.getInputStream())) { while (scanner.hasNext()) { String next = scanner.next(); if ("SerialNumber".equalsIgnoreCase(next)) { continue; } if (scanner.hasNext()) { return scanner.next().trim(); } } } return ""; } }提示:
dmidecode和wmic命令可能需要管理员/root权限才能获取到完整的硬件序列号。在生产环境中,请确保你的应用有足够的权限执行这些命令,或者考虑使用其他跨平台的、权限要求更低的硬件信息获取方案。
3.3 创建自定义的License管理器
这是最关键的一步。我们需要继承 TrueLicense 的LicenseManager,并重写其install和verify方法,将我们自定义的LicenseCheckModel校验逻辑嵌入进去。
package com.yourcompany.license.core; import de.schlichtherle.license.*; import com.yourcompany.license.model.LicenseCheckModel; import lombok.extern.slf4j.Slf4j; import java.beans.XMLDecoder; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.util.List; @Slf4j public class CustomLicenseManager extends LicenseManager { public CustomLicenseManager(LicenseParam param) { super(param); } /** * 重写安装方法,在安装时解码并存储额外的校验信息 */ @Override protected synchronized byte[] install( final byte[] licenseKey, final LicenseNotary notary) throws Exception { // 1. 调用父类方法进行基础安装和验证(有效期、签名等) final LicenseContent content = (LicenseContent) super.install(licenseKey, notary); // 2. 获取License内容中的额外数据(我们加密进去的LicenseCheckModel的XML格式) final byte[] extra = content.getExtra(); if (null != extra) { try (XMLDecoder decoder = new XMLDecoder(new BufferedInputStream(new ByteArrayInputStream(extra)))) { LicenseCheckModel expectedModel = (LicenseCheckModel) decoder.readObject(); log.info("从License文件中读取到的预期服务器信息:{}", expectedModel); // 3. 将读取到的预期信息存储到某个地方(如内存、静态变量、Spring容器),供后续验证使用 LicenseHolder.setExpectedServerInfo(expectedModel); } catch (Exception e) { log.error("解析License额外信息失败", e); throw new LicenseContentException("无效的许可证内容:无法解析额外校验信息"); } } else { log.warn("安装的许可证未包含额外的硬件校验信息,将只进行基础有效期验证。"); } // 4. 返回原始的licenseKey字节数组 return licenseKey; } /** * 重写验证方法,在验证时加入自定义的服务器硬件校验 */ @Override protected synchronized void verify(final LicenseNotary notary) throws Exception { // 1. 调用父类方法进行基础验证(有效期、签名等) super.verify(notary); // 2. 获取当前服务器的实际硬件信息 AbstractServerInfos serverInfos = new LinuxServerInfos(); // 可根据系统类型动态选择 // AbstractServerInfos serverInfos = new WindowsServerInfos(); LicenseCheckModel currentModel = serverInfos.getServerInfos(); log.info("当前服务器的硬件信息:{}", currentModel); // 3. 获取License文件中预期的硬件信息 LicenseCheckModel expectedModel = LicenseHolder.getExpectedServerInfo(); if (expectedModel == null) { log.info("许可证未设置硬件绑定,跳过自定义校验。"); return; // 如果没有设置额外校验信息,则只做基础验证 } // 4. 开始逐项比对 StringBuilder errMsg = new StringBuilder(); // 4.1 校验IP地址 List<String> expectedIps = expectedModel.getIpAddress(); List<String> currentIps = currentModel.getIpAddress(); if (expectedIps != null && !expectedIps.isEmpty()) { boolean ipMatch = currentIps.stream().anyMatch(expectedIps::contains); if (!ipMatch) { errMsg.append("服务器IP地址不匹配。"); } } // 4.2 校验MAC地址 List<String> expectedMacs = expectedModel.getMacAddress(); List<String> currentMacs = currentModel.getMacAddress(); if (expectedMacs != null && !expectedMacs.isEmpty()) { boolean macMatch = currentMacs.stream().anyMatch(expectedMacs::contains); if (!macMatch) { errMsg.append("服务器MAC地址不匹配。"); } } // 4.3 校验CPU序列号 String expectedCpu = expectedModel.getCpuSerial(); String currentCpu = currentModel.getCpuSerial(); if (expectedCpu != null && !expectedCpu.trim().isEmpty()) { if (!expectedCpu.equalsIgnoreCase(currentCpu)) { errMsg.append("服务器CPU序列号不匹配。"); } } // 4.4 校验主板序列号 String expectedBoard = expectedModel.getMainBoardSerial(); String currentBoard = currentModel.getMainBoardSerial(); if (expectedBoard != null && !expectedBoard.trim().isEmpty()) { if (!expectedBoard.equalsIgnoreCase(currentBoard)) { errMsg.append("服务器主板序列号不匹配。"); } } // 5. 如果有任何一项校验失败,则抛出异常 if (errMsg.length() > 0) { log.error("许可证自定义校验失败:{}", errMsg); throw new LicenseContentException("许可证验证失败:" + errMsg); } log.info("自定义硬件信息校验通过。"); } }这里引入了一个LicenseHolder类,它是一个简单的工具类,用于在install和verify方法之间传递LicenseCheckModel对象。你可以根据实际情况选择将其存储在静态变量、ThreadLocal 或 Spring 容器中。
package com.yourcompany.license.core; import com.yourcompany.license.model.LicenseCheckModel; /** * 用于临时持有从License文件中读取的预期服务器信息 */ public class LicenseHolder { private static final ThreadLocal<LicenseCheckModel> EXPECTED_INFO_HOLDER = new ThreadLocal<>(); public static void setExpectedServerInfo(LicenseCheckModel model) { EXPECTED_INFO_HOLDER.set(model); } public static LicenseCheckModel getExpectedServerInfo() { return EXPECTED_INFO_HOLDER.get(); } public static void clear() { EXPECTED_INFO_HOLDER.remove(); } }3.4 配置Spring Bean并实现自动安装
最后,我们需要将自定义的LicenseManager配置为Spring Bean,并确保应用在启动时自动执行许可证的安装和验证。
配置类:LicenseConfig
package com.yourcompany.license.config; import com.yourcompany.license.core.CustomLicenseManager; import de.schlichtherle.license.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import java.io.File; import java.util.prefs.Preferences; @Configuration public class LicenseConfig { @Value("${license.subject}") private String subject; @Value("${license.public-alias}") private String publicAlias; @Value("${license.store-pass}") private String storePass; @Value("${license.license-path}") private String licensePath; @Value("${license.public-keys-store-path}") private String publicKeysStorePath; @Bean public LicenseParam licenseParam() { Preferences preferences = Preferences.userNodeForPackage(LicenseConfig.class); CipherParam cipherParam = new DefaultCipherParam(storePass); KeyStoreParam publicStoreParam = new CustomKeyStoreParam( LicenseConfig.class, publicKeysStorePath, publicAlias, storePass, null // 公钥库不需要保护密码 ); return new DefaultLicenseParam(subject, preferences, publicStoreParam, cipherParam); } @Bean public CustomLicenseManager licenseManager(LicenseParam licenseParam) { return new CustomLicenseManager(licenseParam); } /** * 自定义的KeyStoreParam,用于从类路径或文件系统加载密钥库 */ private static class CustomKeyStoreParam implements KeyStoreParam { private final Class<?> clazz; private final String resourcePath; private final String alias; private final String storePwd; private final String keyPwd; public CustomKeyStoreParam(Class<?> clazz, String resourcePath, String alias, String storePwd, String keyPwd) { this.clazz = clazz; this.resourcePath = resourcePath; this.alias = alias; this.storePwd = storePwd; this.keyPwd = keyPwd; } @Override public String getAlias() { return alias; } @Override public String getStorePwd() { return storePwd; } @Override public String getKeyPwd() { return keyPwd; } @Override public java.io.InputStream getStream() throws java.io.IOException { Resource resource = new ClassPathResource(resourcePath); if (resource.exists()) { return resource.getInputStream(); } else { // 如果类路径下没有,尝试作为绝对文件路径加载 File file = new File(resourcePath); if (file.exists()) { return new java.io.FileInputStream(file); } else { throw new java.io.FileNotFoundException("密钥库文件未找到: " + resourcePath); } } } } }启动安装器:LicenseInstaller为了让验证在应用启动时自动执行,我们可以实现一个CommandLineRunner或ApplicationRunner。
package com.yourcompany.license.runner; import com.yourcompany.license.core.CustomLicenseManager; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.io.File; @Component @Order(1) // 确保在应用业务逻辑启动前执行 @Slf4j public class LicenseInstaller implements CommandLineRunner { @Resource private CustomLicenseManager licenseManager; @Value("${license.license-path}") private String licensePath; @Override public void run(String... args) throws Exception { log.info("开始安装并验证许可证..."); try { // 处理路径,支持 classpath: 前缀和绝对路径 File licenseFile; if (licensePath.startsWith("classpath:")) { String path = licensePath.substring("classpath:".length()); licenseFile = new File(this.getClass().getClassLoader().getResource(path).toURI()); } else { licenseFile = new File(licensePath); } if (!licenseFile.exists()) { throw new RuntimeException("许可证文件不存在,路径: " + licensePath); } // 执行安装(内部会调用我们重写的 install 和 verify 方法) licenseManager.install(licenseFile); log.info("许可证安装并验证成功!"); } catch (Exception e) { log.error("许可证安装或验证失败,应用启动中止。", e); // 验证失败,可以选择让SpringBoot应用直接退出 System.exit(-1); } } }至此,客户端的核心验证逻辑就全部完成了。当你的SpringBoot应用启动时,LicenseInstaller会自动运行,加载并验证许可证文件。如果文件不存在、过期、签名无效或硬件信息不匹配,应用将启动失败。
4. 服务端生成License文件(简易版)
虽然本文重点在客户端验证,但一个完整的流程离不开服务端的License生成工具。这里提供一个高度简化的、基于SpringBoot Test的生成器示例,你可以将其作为一个独立的控制台应用或管理后台的一个功能。
首先,你需要将之前生成的privateKeys.store私钥库文件放到服务端项目的资源目录下(例如src/main/resources/license/privateKeys.store)。
License生成服务类:LicenseCreatorService
package com.yourcompany.license.server; import de.schlichtherle.license.*; import com.yourcompany.license.model.LicenseCheckModel; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import java.beans.XMLEncoder; import java.io.ByteArrayOutputStream; import java.util.Date; @Service @Slf4j public class LicenseCreatorService { @Value("${license.private-alias:privateKey}") private String privateAlias; @Value("${license.key-pass:123456}") private String keyPass; @Value("${license.store-pass:123456}") private String storePass; @Value("${license.private-keys-store-path:classpath:/license/privateKeys.store}") private String privateKeysStorePath; /** * 生成License文件 * @param issuedTime 生效时间 * @param expiryTime 失效时间 * @param consumerAmount 授权数量 * @param checkModel 自定义校验信息(如绑定的硬件信息) * @param licensePath 生成的License文件输出路径 * @return 是否成功 */ public boolean generateLicense(Date issuedTime, Date expiryTime, Integer consumerAmount, LicenseCheckModel checkModel, String licensePath) { try { // 1. 构建License参数 LicenseParam licenseParam = initLicenseParam(); // 2. 构建License内容 LicenseContent content = new LicenseContent(); content.setSubject("my-springboot-app"); // 必须与客户端配置的subject一致 content.setIssuer("YourCompany"); content.setIssued(issuedTime); content.setNotBefore(issuedTime); content.setNotAfter(expiryTime); content.setConsumerType("user"); content.setConsumerAmount(consumerAmount); content.setInfo("Your Product License"); // 3. 将自定义校验信息作为Extra数据嵌入 if (checkModel != null) { try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); XMLEncoder encoder = new XMLEncoder(baos)) { encoder.writeObject(checkModel); encoder.close(); content.setExtra(baos.toByteArray()); } } // 4. 使用私钥对内容进行签名并生成License文件 LicenseManager licenseManager = new LicenseManager(licenseParam); licenseManager.store(content, new java.io.File(licensePath)); log.info("License文件生成成功,路径:{}", licensePath); return true; } catch (Exception e) { log.error("生成License文件失败", e); return false; } } private LicenseParam initLicenseParam() throws Exception { Preferences preferences = Preferences.userNodeForPackage(LicenseCreatorService.class); CipherParam cipherParam = new DefaultCipherParam(storePass); KeyStoreParam privateStoreParam = new CustomKeyStoreParam( LicenseCreatorService.class, privateKeysStorePath, privateAlias, storePass, keyPass // 私钥需要密钥密码 ); return new DefaultLicenseParam("my-springboot-app", preferences, privateStoreParam, cipherParam); } // 自定义KeyStoreParam实现,与服务端配置类似,略... private static class CustomKeyStoreParam implements KeyStoreParam { // ... 实现与客户端配置类中的内部类类似,用于加载私钥库 // 关键区别在于 getKeyPwd() 方法需要返回私钥的密码 } }然后,你可以编写一个简单的Controller或单元测试来调用这个服务,传入客户服务器的硬件信息(需要客户提供),生成对应的.lic文件,再分发给客户。
// 一个简单的测试用例 @SpringBootTest public class LicenseGeneratorTest { @Autowired private LicenseCreatorService licenseCreatorService; @Test public void testGenerateLicense() { LicenseCheckModel checkModel = new LicenseCheckModel(); // 假设客户服务器的IP是 192.168.1.100, MAC地址是 AA-BB-CC-DD-EE-FF checkModel.setIpAddress(Arrays.asList("192.168.1.100")); checkModel.setMacAddress(Arrays.asList("AA-BB-CC-DD-EE-FF")); // CPU和主板序列号如果不需要绑定,可以留空 // checkModel.setCpuSerial("xxxx"); // checkModel.setMainBoardSerial("yyyy"); Calendar issueCal = Calendar.getInstance(); Calendar expiryCal = Calendar.getInstance(); expiryCal.add(Calendar.YEAR, 1); // 有效期一年 boolean success = licenseCreatorService.generateLicense( issueCal.getTime(), expiryCal.getTime(), 1, // 授权数量 checkModel, "/tmp/license.lic" // 生成的文件路径 ); assertTrue(success); } }5. 进阶优化与生产环境考量
当你跑通了上面的基础流程,已经可以应对大多数场景。但如果要用于严肃的生产环境,还有一些细节需要考虑和优化。
1. 动态选择服务器信息获取策略我们的CustomLicenseManager中硬编码了new LinuxServerInfos()。更好的做法是根据当前操作系统动态选择。
// 在 CustomLicenseManager 的 verify 方法中 AbstractServerInfos serverInfos; String os = System.getProperty("os.name").toLowerCase(); if (os.contains("win")) { serverInfos = new WindowsServerInfos(); } else { serverInfos = new LinuxServerInfos(); // 默认Linux,也可支持其他Unix-like系统 } LicenseCheckModel currentModel = serverInfos.getServerInfos();2. 验证失败的处理策略目前我们是在启动时验证,失败则直接System.exit(-1)。这很直接,但可能不够友好。可以考虑以下策略:
- 优雅降级:验证失败后,应用仍可启动,但进入“试用模式”或“功能受限模式”,并在日志和界面上给出明确提示。
- 定时验证:除了启动时验证,还可以通过定时任务(如使用
@Scheduled)定期(如每天)检查许可证状态,应对系统时间被篡改的情况。 - 关键接口拦截:在核心业务接口上添加拦截器或AOP,每次请求都检查许可证状态(或缓存的状态),如果无效则返回特定的错误码。
3. 许可证信息的缓存与更新每次验证都执行dmidecode或wmic命令获取硬件信息,可能会有性能开销。可以考虑在首次获取后,将关键信息(如CPU序列号、主板序列号)缓存到内存或本地文件中,后续验证直接读取缓存。但要注意缓存的安全性和一致性。
4. 对抗调试与反破解TrueLicense 提供了基础的加密保护,但 determined attacker 仍然可能通过反编译、调试等手段进行破解。对于高价值软件,可以考虑增加额外的保护层:
- 代码混淆:使用 ProGuard、Allatori 等工具对客户端JAR包进行混淆。
- 完整性校验:检查自身JAR文件是否被篡改。
- 环境检测:检测是否运行在虚拟机、调试器或常见逆向工具环境中。
5. 许可证管理后台对于需要管理大量客户授权的场景,手动执行命令行生成License文件是不可持续的。你应该开发一个简单的管理后台,实现以下功能:
- 客户信息管理。
- 自动收集客户服务器信息(可通过一个轻量级Agent)。
- 在线生成并下载License文件。
- 许可证状态查询(有效期、使用情况等)。
最后,别忘了进行充分的测试。在不同的操作系统(Windows Server, CentOS, Ubuntu等)、不同的硬件环境下测试你的授权验证模块,确保其稳定性和准确性。我在第一次部署到客户的CentOS服务器上时,就遇到了因为dmidecode命令权限不足导致获取不到序列号的问题,后来通过配置sudo规则解决了。
