SpringBoot整合TrueLicense:软件授权与服务器许可绑定实践 简介一套面向 SpringBoot 开发者的 TrueLicense 授权集成方案解决客户内网部署场景下许可证的生成、安装、校验与卸载问题保护收费软件和企业系统。文档共 1 个 PDF 文件压缩包大小 4.13MB按实战顺序组织从添加核心依赖开始依次介绍服务器校验参数、Linux/Windows 硬件信息获取、许可证创建参数、自定义许可证管理器、证书配置、密钥库生成、服务层与接口控制层、客户端启动校验等环节并附有源码片段和配置说明。内容分服务端与客户端展开服务端负责创建证书与生成许可文件客户端负责安装许可和启动校验还可在登录等关键操作处二次校验。已有 4985 人学习下载适合中高级 Java 工程师参考。读者可重点掌握 IP、MAC、CPU 序列号、主板序列号的绑定校验思路避免只校验有效期带来的授权风险并复用完整代码结构、密钥库配置与证书生成流程降低实现成本。1. SpringBoot 整合 TrueLicense为什么你的软件需要一个授权机制某公司交付私有化系统时客户把服务器系统时间直接改到 2035 年一个按年授权的软件就这么被白嫖了一年。License 方案的价值不在于“到期不让用”而在于“不让用的时候你真的拦得住”。SpringBoot 整合 TrueLicense就是把授权文件做成用私钥签过名的 .lic 文件再和设备硬件信息绑定做服务器许可客户端每次启动和每次请求都用公钥验签。适合正在做私有化部署、需要按客户维度管理授权期限和功能范围的团队。下面这套方案我已经在两个交付项目里落地过密钥对生成、授权服务、客户端校验、机器码绑定和踩坑记录都会讲到照着做就能跑通。2. TrueLicense 授权模型与密钥对生成先用 KeyTool 把地基打牢2.1 License 的生成、安装、校验三环节TrueLicense 本质上是把“授权”这件事从数据库搬到了文件签名上。很多自研方案的做法是在数据库里存一个到期时间登录时读出来比一下。这个方案的脆弱点在于数据库在客户手里客户能改数据等于把钥匙放在锁旁边。TrueLicense 用非对称加密解决这个问题服务端用私钥对许可内容签名生成 .lic 文件客户端只有公钥公钥只能验签不能签发。客户就算把整个 JKS 库拖回去也造不出一份合法的 .lic 文件。整个授权周期拆成三个环节环节使用密钥产物 / 输入执行位置生成私钥 privateAlias产出 .lic 文件公司内部授权服务安装无把 .lic 放到指定目录客户服务器校验公钥 publicCert读取 .lic 并验签应用启动时、请求时这里有个容易忽略的点安装环节本身不需要密钥客户拿到的 .lic 文件是密文签名后的结果他们只能“装”不能“改”。哪怕 .lic 文件被十六进制编辑器打开改一个字节验签都会失败。TrueLicense 的这种设计把信任边界从服务器迁移到了签名本身只要私钥不泄露授权体系就是完整的。我在项目里见过一种误用有人把 .lic 文件内容解析出来把到期时间展示在页面上然后拿数据库里的时间去做是否过期的判断。这样做等于绕过了 TrueLicense 的校验数据库一改就失效。正确的做法是只认 verify() 的结果数据库里的时间只做展示不做判定。2.2 KeyTool 生成密钥对命令、参数与文件规划在写代码之前我一般先把密钥库准备好。TrueLicense 默认使用 JKS 格式的 KeyStore生成方式是用 JDK 自带的 keytool不需要额外工具。# 1. 生成私钥库里面同时包含私钥和自签名证书 keytool -genkeypair -alias privateKey -keyalg RSA -keysize 2048 -validity 3650 \ -keystore privateKeys.store -storepass changeit -keypass changeit \ -dname CN某公司, OU某部门, O某公司, L某市, S某省, CCN # 2. 从私钥库导出证书 keytool -exportcert -alias privateKey -keystore privateKeys.store \ -storepass changeit -rfc -file certfile.cer # 3. 把证书导入公钥库 keytool -importcert -alias publicCert -file certfile.cer \ -keystore publicKeys.store -storepass changeit第一步的 -validity 3650 表示密钥对自签名证书有效期 10 年这个值建议给足。License 文件本身的有效期是写在 LicenseContent 里的和密钥对的 validity 无关但密钥对太短会带来后续续签的麻烦。第二步导出的 certfile.cer 是中间产物第三步执行完后就可以删掉。参数说明参数含义备注-alias密钥别名生成和校验时必须对应不能改-keyalg密钥算法固定 RSA-keysize密钥长度2048 起步别用 1024-validity证书有效天数建议 3650-storepass密钥库口令保护整个 .store 文件-keypass私钥口令保护密钥库中的私钥-dname证书持有者信息按实际填写CN 建议用公司主体文件规划上privateKeys.store 只能留在公司内部授权服务所在的环境绝不能跟着客户端工程走。publicKeys.store 是要打进客户端工程或者放到客户服务器外部目录的。有人问公钥库泄露有没有风险公钥本身没有风险有风险的是 publicKeys.store 被替换——如果客户用一对自己的密钥生成 .lic再替换掉公钥库就能绕过授权。这个问题我会在第 5 章专门讲排查方案。提示storepass 和 keypass 不要写在代码里硬编码至少放到启动参数或环境变量里。密钥库口令一旦泄露虽然公钥库不能用来签发但私钥库如果也在同一台机器上被拖走整个授权体系就废了。3. SpringBoot 整合 TrueLicense 的授权服务核心代码与参数配置3.1 服务端 LicenseCreator生成 .lic 文件的关键代码服务端要做的事很纯粹接收授权参数加载私钥库把许可内容用私钥签名后输出 .lic 文件。下面是我在项目里一直在用的简化结构。先定义一个参数对象 LicenseCreatorParam把签发时要用的信息都收进来public class LicenseCreatorParam { private String subject; // 授权主体标识客户端校验时必须一致 private String holder Default; // 持有人 private String privateAlias; // 私钥别名对应 keytool 里的 alias private String keyPass; // 私钥口令 private String storePass; // 密钥库口令 private String privateKeysStorePath; // privateKeys.store 的磁盘路径 private String licensePath; // 生成的 .lic 输出路径 private Date issuedTime; // 生效时间 private Date expiryTime; // 过期时间 private String consumerType User; // 用户类型比如 User/CPU/Concurrent private int consumerAmount 1; // 授权数量 private String description; // 描述信息 private String extra; // 扩展字段放 JSON // getter / setter 省略 }LicenseManager 是 TrueLicense 的核心类。不同版本的 LicenseParam 构造参数有差异我通常封装一层把密钥库、别名、口令都收敛到一个子类里public class CustomLicenseManager extends LicenseManager { private final KeyStore keyStore; private final String alias; private final char[] keyPwd; public CustomLicenseManager(LicenseParam param, KeyStore keyStore, String alias, char[] keyPwd) { super(param); this.keyStore keyStore; this.alias alias; this.keyPwd keyPwd; } Override protected KeyStore getKeyStore() { return keyStore; } Override protected String getAlias() { return alias; } Override protected char[] getKeyPwd() { return keyPwd; } }生成动作集中在 LicenseCreator 里public class LicenseCreator { private static final Logger log LoggerFactory.getLogger(LicenseCreator.class); private final LicenseCreatorParam param; public LicenseCreator(LicenseCreatorParam param) { this.param param; } public synchronized boolean create() { try { // 1. 加载私钥库 KeyStore keyStore KeyStore.getInstance(JKS); try (InputStream is new FileInputStream(param.getPrivateKeysStorePath())) { keyStore.load(is, param.getStorePass().toCharArray()); } // 2. 组装 LicenseParam不同版本的构造参数顺序可能不同以依赖源码为准 LicenseParam licenseParam new DefaultLicenseParam( param.getSubject(), param.getPrivateAlias(), keyStore, param.getKeyPass() null ? null : param.getKeyPass().toCharArray()); // 3. 进程内复用 LicenseManager 单例 LicenseManager manager LicenseManagerHolder.getManager( licenseParam, keyStore, param.getPrivateAlias(), param.getKeyPass() null ? null : param.getKeyPass().toCharArray()); // 4. 填充许可内容 LicenseContent content new LicenseContent(); content.setSubject(param.getSubject()); content.setHolder(param.getHolder()); content.setIssued(param.getIssuedTime()); content.setExpiry(param.getExpiryTime()); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setInfo(param.getDescription()); // extra 里放机器指纹和功能模块开关 content.setExtra(param.getExtra()); // 5. 输出 .lic 文件 manager.store(content, new File(param.getLicensePath())); log.info(License 生成成功: {}, param.getLicensePath()); return true; } catch (Exception e) { log.error(License 生成失败, e); return false; } } }有两个参数需要特别留意。第一个是 issuedTime我一般会设置为当前时间减 24 小时而不是精确的当前时刻。原因是客户服务器的时钟可能比授权服务慢如果签发时间晚于当前时间客户端启动时会直接报 License not yet valid。第二是 subject它相当于授权内容的命名空间生成和校验两端必须传同一个值常见的做法是用项目代号或公司域名倒写。extra 字段是 Object 类型很多人直接塞一个 String。我建议统一用 JSON 字符串后面你要塞机器指纹、功能模块开关、最大用户数都可以往里加解析也方便。生成成功后把 .lic 文件和对应的机器指纹一起发给客户授权流程就走完了生成这一半。3.2 客户端 LicenseVerify安装、校验与异常处理客户端校验的第一步是拿到公钥库。我一般把 publicKeys.store 放在 src/main/resources 根目录下打包后它会出现在 classpath 里用 getResourceAsStream 直接读public class LicenseVerify { private static final Logger log LoggerFactory.getLogger(LicenseVerify.class); private final LicenseVerifyParam param; public LicenseVerify(LicenseVerifyParam param) { this.param param; } public LicenseContent verify() { try { // 1. 从 classpath 读取公钥库 KeyStore keyStore KeyStore.getInstance(JKS); try (InputStream is getClass().getClassLoader() .getResourceAsStream(param.getPublicKeysStorePath())) { keyStore.load(is, param.getStorePass().toCharArray()); } // 2. 构造 LicenseParam客户端只用公钥相关参数 LicenseParam licenseParam new DefaultLicenseParam( param.getSubject(), param.getPublicAlias(), keyStore, param.getStorePass().toCharArray()); // 3. 获取 LicenseManager 并执行校验 LicenseManager manager LicenseManagerHolder.getManager( licenseParam, keyStore, param.getPublicAlias(), param.getStorePass().toCharArray()); // verify() 内部会完成签名校验和有效期校验 return manager.verify(); } catch (Exception e) { log.error(License 校验失败, e); throw new LicenseException(License 校验失败请检查授权文件, e); } } }这里有个细节verify() 每次都会读取 .lic 文件并做一次 RSA 验签性能上单次是亚毫秒级但如果你在拦截器里对每个请求都调用文件 IO 和异常栈构造会被放大。常见做法是把 verify 结果缓存起来配合定时复验。校验失败时的异常类型也能帮你快速定位问题异常表现常见原因找不到匹配的 subject.lic 文件不存在或生成端和校验端 subject 不一致签名校验失败publicKeys.store 与 privateKeys.store 不是同一对密钥有效期异常系统时间被修改或 issuedTime / expiryTime 设置不当自定义校验异常机器指纹不匹配或授权功能模块不包含当前请求的能力3.3 把校验接入 SpringBoot启动器、拦截器与定时复验SpringBoot 里接入校验有三种常见姿势我一般会组合使用。第一种是启动时硬校验。应用起不来比运行到一半被拦体验证失败要好排查得多。用 ApplicationRunner 实现Component public class LicenseCheckRunner implements ApplicationRunner { Override public void run(ApplicationArguments args) { // 校验失败会抛异常SpringBoot 会中止启动 licenseVerifyService.verifyOrThrow(); } }第二种是拦截器校验。在 preHandle 里做校验不放行就直接返回 403public class LicenseInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri request.getRequestURI(); // 这些路径必须放行健康检查、指纹采集、license 安装 if (uri.startsWith(/actuator) || uri.startsWith(/license/fingerprint) || uri.startsWith(/license/install)) { return true; } licenseVerifyService.verifyOrThrow(); return true; } }注册到 WebMvcConfigurer 里Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(licenseInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/error); } }第三种是定时复验。用 Scheduled 每隔几分钟调一次 verify()主要是为了防止客户在运行期间修改系统时间或者偷偷调换 .lic 文件。校验方式校验时机优点缺点启动校验应用启动时失败就起不来问题暴露早运行中变更无法感知拦截器校验每个请求实时性强每次验签有性能和异常开销定时复验每 N 分钟开销可控存在一个时间窗口我推荐组合启动时硬校验兜底拦截器做轻量校验并缓存结果再配一个 5 分钟的定时复验。这样既保证授权状态不会长时间失真又不会让每个请求都背着一次 RSA 验签。4. 服务器许可绑定机器码采集与设备指纹校验4.1 用 Oshi 采集主板序列号、CPU ID 与 MACLicense 只绑定时间还不够客户把一套授权文件拷到十台服务器上就能无限分发。所以服务器许可是多数 To B 场景的硬需求。采集机器信息业界常见的做法是两条路一条是 Runtime.exec 执行 dmidecode 再去解析输出另一条是用 Oshi 这类跨平台硬件信息库。我更推荐 Oshi因为它不需要 root 权限也不用写一堆脆弱的命令行解析逻辑。Oshi 在 Linux 上读取的是 /sys/class/dmi/id、/proc/cpuinfo 这些内核暴露的接口在 Windows 上走 WMI同样的代码两端通用。dependency groupIdcom.github.oshi/groupId artifactIdoshi-core/artifactId !-- 版本号以 maven 仓库最新稳定版为准 -- /dependency采集逻辑我封装成了一个工具类public class MachineFingerprint { public static String get() { HardwareAbstractionLayer hal new SystemInfo().getHardware(); String boardSerial clean(hal.getComputerSystem().getSerialNumber()); String cpuId clean(hal.getProcessor().getProcessorID()); String mac hal.getNetworkIFs().stream() .filter(NetworkIF::isUp) .map(NetworkIF::getMacaddr) .filter(Objects::nonNull) .findFirst() .map(MachineFingerprint::clean) .orElse(none); String raw String.join(|, boardSerial, cpuId, mac); return DigestUtils.sha256Hex(raw); } private static String clean(String value) { if (value null) { return ; } String v value.trim().replace(:, ).replace(-, ).toUpperCase(Locale.ROOT); // 过滤主板厂商预置的占位符这些值没有任何区分度 if (v.equals(DEFAULT STRING) || v.equals(TO BE FILLED BY O.E.M.) || v.equals(NONE) || v.equals(SYSTEM SERIAL NUMBER)) { return ; } return v; } }clean 方法里过滤的占位符是重点。很多品牌机的主板序列号出厂时就是 Default string 或 To be filled by O.E.M.品牌机之间这一项完全相同如果不过滤它们会全部落进同一个桶里机器码区分度大打折扣。关于 MAC 地址我特意取第一块 isUp 的网卡。服务器上常常有 docker0、br-xxxx 这类虚拟网卡它们的 MAC 地址是随机生成的一旦容器重建就会变。如果机器码里混入虚拟网卡的 MAC会让授权变得很难伺候。取第一块处于 up 状态的物理网卡是多数项目的选择但如果服务器上有多个物理网卡顺序并不稳定所以更稳妥的策略是去掉 MAC 这个因子只用主板序列号和 CPU ID。4.2 把机器指纹写入 License 并在校验时比对拿到机器指纹之后有一套标准的动作流程设计错了后面会很别扭客户在目标服务器上运行一个采集接口或小工具获得机器码客户把机器码发给你你在授权服务端把机器码写进 LicenseContent 的 extra 字段生成 .lic客户安装 .lic应用启动时 verify() 自动比对。这里最容易搞反的是机器指纹必须在客户服务器上采集不是在你自己电脑上采集。所以我一般会在客户端工程里留一个免 License 的指纹接口部署后客户直接访问就能拿到机器码RestController RequestMapping(/license) public class MachineFingerprintController { GetMapping(/fingerprint) public String fingerprint() { return MachineFingerprint.get(); } }这个接口必须放在拦截器放行名单里否则客户第一次还没装 License连机器码都拿不到流程就卡死了。校验那一端我选择重写 CustomLicenseManager 的 validate 方法这样所有 verify() 调用都会自动执行机器码比对业务代码不用感知Override protected synchronized void validate(LicenseContent content) throws LicenseContentException { // 先执行 TrueLicense 自带的签名与有效期校验 super.validate(content); // extra 约定为 JSON{machineFingerprint:sha256...} String extra (String) content.getExtra(); if (extra null || extra.isBlank()) { throw new LicenseContentException(授权文件中缺少机器指纹); } JsonNode node JsonUtil.parse(extra); String expected node.get(machineFingerprint).asText(); String actual MachineFingerprint.get(); if (!expected.equals(actual)) { throw new LicenseContentException(授权文件与当前服务器不匹配); } }有一点要注意如果是公钥验签之后再校验机器码顺序一定不要反。先验签保证 .lic 文件是你们签发的再做机器码比对如果先比对机器码再验签攻击者可以篡改 extra 字段绕过比对。另外重写 validate 是加在客户端工程里的 CustomLicenseManager 上不是加在服务端。服务端只管生成不需要在 store 时校验机器码。生成端和校验端各持一份 CustomLicenseManager 是可以的但逻辑重心在校验端。5. License 落地常见问题排查时间篡改、机器码漂移与密钥库陷阱5.1 系统时间被回拨导致 License 提前失效现象客户拿到 .lic 文件安装后应用启动直接报 License not yet valid但你们内部测试明明是好的。原因签发机器上的系统时间比客户服务器快。TrueLicense 对 issuedTime 和当前时间的比较是严格执行的当前时间只要早于 issuedTime就会被判定为“还未生效”。客户改慢服务器时间或者跨时区部署没有同步时间都会触发这个异常。解决签发时把 issuedTime 设置为当前时间往前拨 24 小时给时钟误差留足余量。另外在客户端校验逻辑中记录一次系统时间偏移量把当前系统时间和请求外部接口返回的时间头做对比偏差超过阈值就告警。私有化环境经常断外网所以不要指望 NTP 兜底签发余量是最实用的防线。5.2 Docker 容器里机器码采集不稳定现象同一台物理机直接在宿主机上跑应用校验通过打成容器镜像部署后校验失败而且换个网络模式机器码又变一次。原因容器有自己的网络命名空间eth0 的 MAC 地址由容器网络模式决定不是物理网卡 MAC/sys/class/dmi/id 在容器里默认不可见Oshi 读不到主板序列号这两个因子一变机器码就变了。解决给容器挂载宿主机的 dmi 目录固定容器 MAC 地址。docker run 时加上 --volume /sys/class/dmi/id:/sys/class/dmi/id:ro。如果现场不方便挂载机器码算法就只保留 CPU ID 作为主因子主板序列号作为辅助MAC 干脆不参与。我在另一个容器化交付项目里就是这么调整的稳定了很多。这类问题比较玄学的是同一个镜像在不同宿主机上表现还不一样排查时建议先在容器内跑一遍指纹采集接口把实际采集到的值打出来对比。5.3 公钥库文件被替换导致校验失败现象客户更新应用版本后所有 .lic 文件全部校验失败异常是签名不匹配。原因publicKeys.store 被新版部署包含的另一个公钥库覆盖了或者客户侧有人尝试用自己生成的密钥对替换公钥库。解决不要把 publicKeys.store 打进 jar 包放到外部 config 目录部署脚本固化它的 SHA-256 校验值变更就走告警。更稳妥的是在代码里硬编码公钥库文件的指纹加载后先比对不一致直接拒绝初始化String expected 硬编码的公钥库SHA256; String actual DigestUtils.sha256Hex(Files.readAllBytes(publicKeysPath)); if (!expected.equals(actual)) { throw new LicenseException(公钥库完整性校验失败); }公钥本身没有保密价值但公钥库一旦被替换客户就能用自己的密钥签发 License整个授权体系就形同虚设。所以公钥库的完整性比机密性更重要。5.4 应用重启后提示找不到 License 文件现象本地 IDEA 里跑得好好的部署到服务器上第一次启动也正常重启一次后报找不到 .lic 文件。原因licensePath 配置的是相对路径比如 license/license.lic。IDEA 的工作目录是项目根目录systemd 的 WorkingDirectory 可能是别的路径两种环境下相对路径指向的位置完全不同。解决License 文件路径一律走配置项注入不要用相对路径。在 application.yml 里配 license.path/etc/app/license.licApplicationRunner 启动时先检查文件是否存在不存在就打印期望的绝对路径方便现场运维一眼看到问题。另外要把 .lic 文件视为部署产物的一部分写进部署脚本的校验清单里避免重启后文件被临时目录清理。5.5 License 到期时间与业务长会话冲突现象License 到期的瞬间所有在线用户突然被踢出系统正在编辑的数据来不及保存。原因拦截器里对每个请求都执行了严格的到期校验业务会话跨过了到期时间点就被无差别拦截。解决把“授权是否有效”和“当前用户是否能继续操作”拆开判断。给到期时间加一个宽限期比如到期后 24 小时内允许已登录用户继续只读操作但拦截写接口同时前端在到期前 7 天开始弹出续费提醒。真正到了宽限期结束再强制要求重新导入新的 .lic 文件。这样既不会让用户措手不及也不会无限纵容过期使用。6. 进阶玩法用 extra 字段做功能模块授权与在线激活6.1 用扩展字段控制功能模块开关LicenseContent 的 extra 字段是个万能口袋。我在交付项目里常用它承载功能模块授权一份 .lic 文件同时管住授权期限、服务器许可、模块范围三个维度。extra 里放这样的 JSON 结构{ machineFingerprint: sha256机器码, modules: [report, export], maxUsers: 50 }后端拦截器里解析 modules比如 /export/** 路径要求 export 模块在授权范围内不在就直接返回 403前端启动时通过 /license/info 接口拉取 modules动态渲染菜单未授权的模块连入口都不展示。这样销售在签约时谈好哪些功能交付时重新生成一个 .lic 文件就完成了功能授权调整不需要为每个客户单独出包。6.2 在线激活、离线授权的选型与混合思路TrueLicense 原生是离线授权拿到机器码生成 .lic导入完成。私有化部署优先走这条链路流程简单不依赖客户网络。如果要做成在线激活本质不是换签名体系而是换传输方式应用首次启动时带着机器码请求激活中心激活中心校验订单后返回 .lic 文件内容客户端保存到本地再走原有 verify() 流程。这种混合方式适合半私有化场景客户能连网但你又希望授权发放是可控的。在线激活的关键是激活接口的幂等设计同一台机器重复激活不要重复扣授权数License 里带一个激活记录 ID 便于后续追溯吊销。我最开始做这套授权方案的时候把机器码绑定得特别死主板序列号、CPU ID、MAC 三要素必须全等结果客户换了一块网卡服务就起不来现场运维直接电话打过来。后来我把策略改成了三选二容错有两个因子匹配就算通过单个硬件漂移只告警不拒绝。License 是约束合同履约的工具不是给自己制造运维事故的玩具这个度希望你一开始就拿捏好。希望帮到你。本文还有配套的精品资源点击获取