☰
SpringBoot整合TrueLicense实现内网授权与指纹校验
2026/11/3 10:55:24 网站建设 项目流程

简介:这份 PDF 文档围绕 SpringBoot 与 TrueLicense 的整合展开,面向需要在客户内网、离线环境下实施软件授权控制的 Java 后端开发者与架构人员,重点解决许可证签发、安装与启动校验的落地问题。压缩包内共 1 个 PDF 文件,约 4.13MB,以代码清单、类结构说明和配置要点为主,覆盖依赖引入、校验参数模型、服务器硬件信息获取思路、密钥库与证书生成、许可创建与校验流程等模块,可按章节对照实现。已有 4993 人学习,具备一定的实践参考基础。读者可从中获得一套可复用的许可校验模型设计(IP、MAC 地址、CPU 序列号、主板序列号),掌握 keytool 生成公私钥证书库的思路,并理清服务端签发、客户端安装与关键操作时复核的职责划分,便于在自有项目中搭建许可分发与管理链路。

1. 内网部署的 SpringBoot 项目,为什么最后都得走 License 授权这条路

去年给某客户内网部署过一套 SpringBoot 系统,断网、无外网出口、机房还不让随便进。一开始想的是账号登录授权,落地才发现客户连你远端的账号中心都连不上,账号体系在内网这层就是摆设。后来改成服务器许可文件:应用启动时读取 License,校验有效期、服务器 IP、MAC、CPU 序列号、主板序列号,不通过直接拒绝启动。这就是 SpringBoot 整合 TrueLicense 实现 License 授权与服务器许可的典型场景。TrueLicense 1.33 给的是证书签发、校验的骨架,但自带的verify只校验生效和过期时间,硬件指纹这些得自己补。这篇按能复现的顺序拆:密钥库怎么生成、指纹怎么采、许可怎么签、安装和校验接在哪、哪几处最容易翻车。

2. 授权模型选型与 TrueLicense 的校验边界:自带 verify 到底管到哪一步

2.1 云部署与内网部署,授权链路根本不是一个东西

选型先要分清部署位置,否则方案选错,后面全是补丁。应用部署在自家云服务器上,用户通过账号远程访问,登录时校验账号有效期、权限、并发数就行,授权逻辑全在服务端可控范围内。应用部署在客户内网,开发者拿不到网络控制权,也没法保证客户机器能出外网,账号体系就不可靠了,这时授权只能落到本地文件上:客户机器上放一份 License,应用启动和关键操作时读出来比对。

文件授权有一个绕不开的矛盾:文件放在客户手里,客户就能改。纯配置文件、纯 JSON 记录有效期,改一下日期就绕过去了。所以文件本身必须带防篡改能力,常见做法是用非对称签名——研发侧留私钥签发,客户端只装公钥证书验签,客户改文件就等于破坏签名。TrueLicense 用的就是这套 PKI 模型,私钥库由研发侧保管,客户机只持有公钥证书和 License 文件。硬件加密狗当然更硬,但成本、物流、丢失补发都是问题,内网批量部署场景里往往不划算。

TrueLicense 的定位就在这里:它不是把授权逻辑写死,而是给你一套「签发—安装—校验」的证书生命周期管理,业务层面的授权规则(哪些硬件算合法、有效期怎么分段、试用和正式怎么区分)留给你自己实现。所以后面所有的定制,都是围绕这个边界展开的。

2.2 TrueLicense 1.33 的 verify 只校验了什么

拆开de.schlichtherle.license.LicenseManager看,verify方法主要做三件事:读取 License 文件、用公钥证书验证签名链、比对证书的notBefore和notAfter时间窗口。也就是说它只回答「这份许可证是不是我签的、现在还在不在有效期内」,不回答「这份许可证是不是发给这台机器的」。

源代码里LicenseManager.verify调用validate之前会先做extractLicense,把 License 里的extra字段反序列化成对象,但不会主动拿它去和本机做比对。业务上要校验 IP、MAC、CPU 序列号、主板序列号,必须在verify之后追加一层自定义校验,或者在LicenseManager子类里重写validate方法。两种做法都有人在用,我一般选后者,原因有两个:一是校验失败时能直接抛出LicenseContentException,异常链路和 TrueLicense 自身的错误处理保持一致;二是校验逻辑集中在一处,后面要加「指纹容错」或者「灰度放行」时不用到处找入口。

重写verify的一个参考骨架长这样,核心思路是先走父类校验,再补硬件指纹比对:

public class CustomLicenseManager extends LicenseManager { private final LicenseCheckModel expectedModel; private final String osName = System.getProperty("os.name").toLowerCase(); public CustomLicenseManager(LicenseParam param, LicenseCheckModel expectedModel) { super(param); this.expectedModel = expectedModel; } @Override protected synchronized LicenseContent verify(LicenseContent content) throws LicenseContentException { // 1. 先让父类完成签名链和有效期校验 LicenseContent verified = super.verify(content); // 2. 再做硬件指纹比对 AbstractServerInfos serverInfos = isWindows() ? new WindowsServerInfos() : new LinuxServerInfos(); LicenseCheckModel actual = serverInfos.getServerInfos(); if (!matches(actual, expectedModel)) { throw new LicenseContentException("服务器硬件指纹与许可不一致"); } return verified; } private boolean isWindows() { return osName.contains("win"); } }

这里有两个参数要特别注意:expectedModel是从 License 文件extra字段里反序列化出来的允许值,不是从数据库读的,所以它天然带签名保护,客户改了extra签名就失效;osName决定走 Linux 还是 Windows 的实现类,注意大小写要转小写再判断,Windows、WIN、win都可能出现。

2.3 依赖引入与工程骨架

依赖声明本身很简单,但scope这一行有讲究:

<dependency> <groupId>de.schlichtherle.truelicense</groupId> <artifactId>truelicense-core</artifactId> <version>1.33</version> <scope>provided</scope> </dependency>

provided的含义是「编译期可见,运行期由容器提供」。如果项目打成 Spring Boot fat jar 独立部署,容器里根本没有这个依赖,启动就会抛NoClassDefFoundError。独立部署的项目我一般改成默认的compile作用域;只有在公司统一父 POM 管理、运行环境确实已经预置了这个库时,才保留provided。这个坑后面第 5 章还会单独列一条。

工程骨架按职责分包比较清晰:license包放LicenseCheckModel、LicenseCreatorParam、CustomLicenseManager、LicenseVerify;abstracts包放AbstractServerInfos;impl包放LinuxServerInfos、WindowsServerInfos;service包放许可创建和校验的服务;controller层只负责对外暴露签发和查询接口。这样后面加平台适配或者换指纹策略时,改动范围能压在一个包里。

3. 硬件指纹采集:Linux 与 Windows 两套实现如何收敛到一个抽象类

3.1 AbstractServerInfos 用模板方法把差异收口

IP 和 MAC 的取法在两个平台上差别不大,都是通过NetworkInterface遍历;CPU 序列号和主板序列号就完全不同了,Linux 靠dmidecode,Windows 靠wmic,而且命令输出格式、解析方式都不一样。如果各写一套,校验逻辑和 License 创建逻辑都要重复适配两遍。

模板方法模式在这里最合适:父类定义固定的组装流程,子类只负责填四个钩子。父类的getServerInfos负责按顺序取 IP、MAC、CPU、主板,任何一步异常都统一打日志,不中断整体收集;四个protected abstract方法交给子类实现。这样校验侧只依赖AbstractServerInfos,不需要关心当前跑在什么系统上。

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

父类里的公共能力也都收在这里。getLocalAllInetAddress遍历所有网卡并排除回环、链路本地、组播地址;注意isSiteLocalAddress那一行在源码里是被注释掉的,因为内网部署场景下 192.168.x.x、10.x.x.x 正是要采集的地址,一旦排掉,指纹就永远是空的。getMacByInetAddress把byte[]转成XX-XX-XX-XX-XX-XX的大写字符串,低于 16 的字节要补零。这两个方法的设计直接决定了后面多网卡和虚拟网卡场景下会不会翻车。

3.2 Linux 侧:dmidecode 两个命令加一层过滤

Linux 下取 CPU 序列号和主板序列号最直接的工具是dmidecode,它读的是 SMBIOS 表中的原始字段,比/proc/cpuinfo稳定得多(后者在很多虚拟化环境下拿不到序列号)。CPU 序列号走-t processor限定类型,主板序列号走-t baseboard限定类型:

@Override protected String getCPUSerial() throws Exception { // -t processor 限定 DMI 类型,grep ID 取到的是处理器 ID 行 String[] shell = {"/bin/bash", "-c", "dmidecode -t processor | grep 'ID' | awk -F ':' '{print $2}' | head -n 1"}; Process process = Runtime.getRuntime().exec(shell); process.getOutputStream().close(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return line == null ? "" : line.trim(); } } @Override protected String getMainBoardSerial() throws Exception { // 加 -t baseboard 是关键,不加的话第一条 Serial Number 可能是机箱或内存 String[] shell = {"/bin/bash", "-c", "dmidecode -t baseboard | grep 'Serial Number' | awk -F ':' '{print $2}' | head -n 1"}; Process process = Runtime.getRuntime().exec(shell); process.getOutputStream().close(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return line == null ? "" : line.trim(); } }

两个命令的参数含义要理解清楚:-t processor和-t baseboard是 DMI 类型限定,grep用来定位字段行,awk -F ':' '{print $2}'按冒号切出值,head -n 1只取第一条。主板序列号这里如果只写dmidecode | grep 'Serial Number'不加-t baseboard,第一条命中的往往是机箱序列号,取出来的值和主板的真实值对不上,这是我自己踩过的坑,后面第 5 章单独展开。

还有一件必须提前处理的事:dmidecode需要 root 权限,非 root 执行会报Permission denied。生产环境里我看到过两种做法——申请一个只允许执行dmidecode的 sudoers 条目,或者给应用配一个专用的 systemd 服务并设置CAP_SYS_RAWIO能力。两种都能用,前者更常见,但要评估客户安全策略是否接受。

3.3 Windows 侧:WMIC 输出第一行要跳过

Windows 下用wmic cpu get processorid和wmic baseboard get serialnumber,输出格式和 Linux 不一样:先是表头,再是值,最后可能还带空行。用Scanner读的时候要先把第一行next()掉,否则拿到的就是ProcessorId这个字段名而不是真实值。

@Override protected String getCPUSerial() throws Exception { Process process = Runtime.getRuntime().exec("wmic cpu get processorid"); process.getOutputStream().close(); String serialNumber = ""; try (Scanner scanner = new Scanner(process.getInputStream())) { if (scanner.hasNext()) { scanner.next(); // 跳过表头 ProcessorId } if (scanner.hasNext()) { serialNumber = scanner.next().trim(); } } return serialNumber; } @Override protected String getMainBoardSerial() throws Exception { Process process = Runtime.getRuntime().exec("wmic baseboard get serialnumber"); process.getOutputStream().close(); String serialNumber = ""; try (Scanner scanner = new Scanner(process.getInputStream())) { if (scanner.hasNext()) { scanner.next(); // 跳过表头 SerialNumber } if (scanner.hasNext()) { serialNumber = scanner.next().trim(); } } return serialNumber; }

参数上还要注意 Windows 默认编码问题,Scanner如果不指定字符集,中文版系统下偶尔会读出乱码,通常做法是在构造Scanner时传入Charset.forName("GBK")。另外wmic在部分精简版 Windows Server 上被移除,取不到值时返回空字符串,校验侧要对空值做容错,不能因为取不到就判定为「非法机器」。

3.4 指纹归一化:多网卡、大小写、虚拟网卡

原始采集出来的数据不能直接拿来比对。同一台机器,网卡顺序不同、虚拟网卡插拔、ipAddress大小写不一致,都会让指纹对不上。归一化我一般做三件事:IP 和 MAC 全部转大写或统一小写、多网卡去重、过滤掉明显不是物理网卡的地址。

IP 归一化比较简单,采集完distinct()再去掉回环和169.254开头的地址;MAC 归一化要把分隔符统一成一种,-和:混用会让同一张网卡比出两个值。虚拟网卡过滤是个脏活,常见做法是排除docker0、br-、veth、virbr这类前缀对应的接口,具体规则因客户环境而异,我一般做成可配置的黑名单,而不是写死在代码里。

4. 密钥库与许可文件:keytool 命令到 Controller 的完整链路

4.1 keytool 生成公私钥库

证书链是整条链路的地基,私钥库和公钥库生成不对,后面所有签名都会失败。私钥库只在研发侧保存,公钥库随应用打包发给客户,这样客户机器上没有私钥,改 License 就无效。

# 1. 生成私钥库(PKCS12 格式,存放私钥,研发侧保管) keytool -genkeypair \ -alias privateKey \ -keysize 1024 \ -keystore privateKeys.keystore \ -storepass 123456 \ -keypass 123456 \ -validity 3650 \ -keyalg RSA \ -dname "CN=Demo, OU=Demo, O=Demo, L=Demo, ST=Demo, C=CN" # 2. 从私钥库导出公钥证书 keytool -exportcert \ -alias privateKey \ -keystore privateKeys.keystore \ -file certfile.cer \ -storepass 123456 # 3. 把公钥证书导入公钥库(公钥库随应用部署) keytool -import \ -alias publicCert \ -file certfile.cer \ -keystore publicCerts.keystore \ -storepass 123456 \ -noprompt

三个命令的参数要对应起来看:-alias在三个命令里必须是同一个密钥别名,否则导出的证书和私钥库对不上;-keysize 1024是 TrueLicense 1.33 时代常用的长度,现在也可以上 2048,但两边要保持一致;-storepass和-keypass在 PKCS12 格式下一般要求相同,不同的话部分 JDK 版本会报错;-validity 3650是证书自身的有效期,和业务 License 的有效期是两回事,License 的expiryTime不能超过证书的notAfter。公钥库导入时-noprompt是必须的,否则会卡在交互确认上。

4.2 LicenseCreatorParam 字段和证书 subject 的映射关系

创建许可的时候,参数对象是把「证书信息」和「业务信息」桥接起来的地方,字段映射错一个就会导致签名通过但业务校验失败。

@Data public class LicenseCreatorParam implements Serializable { private static final long serialVersionUID = 1L; /** 证书 subject,要和 keytool 的 -dname 对得上 */ private String subject; /** 私钥别名,对应 keytool 的 -alias */ private String privateAlias; /** 私钥密码,对应 -keypass,只能研发侧持有 */ private String keyPass; /** 密钥库访问密码,对应 -storepass */ private String storePass; /** License 文件生成路径 */ private String licensePath; /** 私钥库存储路径 */ private String privateKeysStorePath; /** 生效时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date issuedTime = new Date(); /** 失效时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date expiryTime; /** 用户类型,默认 user */ private String consumerType = "user"; /** 用户数量,默认 1 */ private Integer consumerAmount = 1; /** 描述信息 */ private String description = ""; /** 额外校验参数 */ private LicenseCheckModel licenseCheckModel; }

subject必须和keytool -dname完全一致,大小写、空格、逗号顺序都不能差,否则KeyStore找不到对应的条目。issuedTime和expiryTime加了@JsonFormat并指定时区,是为了避免前端传参和服务器时区不一致导致许可生效时间偏移几个小时。consumerAmount在 TrueLicense 里是许可数量,多机部署时可以按机器数配置,单机部署保持默认 1 就行。licenseCheckModel是自定义字段,跟着extra一起签进 License,客户端校验时会反序列化出来和本机指纹比对。

4.3 创建、安装、校验三段式服务

服务层按职责拆成三段,接口定义清楚后,客户端和研发侧分别依赖不同的实现。研发侧只依赖创建服务,客户端只依赖安装和校验服务。

public interface ServiceLicenseCreator { /** 生成 License 文件,只运行在研发侧 */ void createLicense(LicenseCreatorParam param); } public interface ServiceLicenseVerify { /** 安装 License,客户端首次部署时调用 */ void install(String licensePath); /** 校验 License,应用启动和关键操作时调用 */ void verify(); /** 卸载 License,租约到期或换机时调用 */ void uninstall(); }

创建侧的核心逻辑是把LicenseCreatorParam转成LicenseContent,再调用CustomLicenseManager.create写入文件。这里有个细节:LicenseContent.setExtra()存的是LicenseCheckModel对象,CustomLicenseManager内部会用ObjectOutputStream序列化它,所以LicenseCheckModel必须实现Serializable并显式声明serialVersionUID。不声明的话,不同 JDK 版本序列化结果可能不一致,客户端反序列化就会报InvalidClassException。

校验侧建议把install和verify分开。install负责把 License 文件复制到应用能读到的固定路径并做首次校验,verify只做读文件加校验,可以放在拦截器里被多次调用。混在一起写会导致每次关键操作都重新复制文件,多实例部署时还可能互相覆盖。

4.4 启动期校验的接入点

Spring Boot 里做启动期校验,通常有三种接法:ApplicationRunner、InitializingBean、@PostConstruct。我一般用ApplicationRunner,因为它执行在所有 Bean 初始化之后,数据库、配置这些都就绪了,校验失败直接抛异常终止启动,不会留下一个「半启动」的应用。

@Component public class LicenseStartupChecker implements ApplicationRunner { private final ServiceLicenseVerify licenseVerify; public LicenseStartupChecker(ServiceLicenseVerify licenseVerify) { this.licenseVerify = licenseVerify; } @Override public void run(ApplicationArguments args) { // 启动时不做安装,只做校验;安装走单独的运维流程 licenseVerify.verify(); } }

这里有个容易被忽略的边界:install和verify的顺序。首次部署时 License 文件还没放上去,如果启动就verify,应用直接起不来,客户连安装接口都调不到。常见做法是首次部署时通过环境变量或者启动参数把启动校验临时关掉,装完 License 再重启开启校验。这个开关不能放在数据库里,否则客户能自己改。

5. 避坑排查:许可校验五类失败现场

5.1 现象一:安装成功,启动报 85 证书无效

现象:客户端执行install返回成功,重启应用后启动日志抛LicenseContentException: 85,异常信息里只有数字没有可读原因。

原因:TrueLicense 的85对应NO_KEY_ENTRY,说明publicCerts.keystore里没有找到与subject匹配的证书条目。最常见的是公钥库导入时-alias和LicenseCreatorParam.subject不一致,次要原因是私钥库重新生成过但公钥库还是旧的,两边证书对不上。

解决:先执行keytool -list -v -keystore publicCerts.keystore看条目别名和 subject,再和执行createLicense时的subject逐字符比对,重点看大小写和dname里的空格。确认无误后如果还报错,多半是公钥库路径没指对,检查LicenseParam.setPublicKeysStorePath()的绝对路径。

5.2 现象二:主板序列号每次都不同,许可换机就失效

现象:同一台 Linux 机器,两次采集到的主板序列号不一样,签出来的 License 装机后校验失败。

原因:采集命令写成了dmidecode | grep 'Serial Number' | head -n 1,没有加-t baseboard。dmidecode的完整输出里,机箱、主板、内存模块都有Serial Number字段,head -n 1命中的是哪一条取决于 SMBIOS 表的排列顺序,换一次内核版本可能就变了。

解决:把命令限定为dmidecode -t baseboard | grep 'Serial Number' | head -n 1,并且在校验侧对空值做过滤。已经签发的 License 全部作废重签,同时在采集逻辑里加日志,把取到的原始值打印出来方便核对。

5.3 现象三:多网卡机器换 IP 后校验失败,但网卡没换

现象:客户机器有多个网卡,主网卡 IP 变了之后许可校验失败,但 MAC、CPU、主板都没变。

原因:getIpAddress采集了所有网卡 IP 并做完整列表比对。客户增加一块虚拟网卡或者调整网卡顺序,列表就变了,哪怕物理网卡一个没动。

解决:不要做全列表严格比对,改成「允许值集合」与「实际值集合」求交集,交集非空即通过;或者只在采集阶段记录一块主网卡的 IP,按网卡是否isUp()、是否属于物理接口来筛选。具体策略因客户环境而异,我一般会提供两种模式,用配置项切换。

5.4 现象四:本地跑得好好的,打包成 jar 后启动报 NoClassDefFoundError

现象:IDE 里正常运行,mvn package打出的 jar 启动时报java.lang.NoClassDefFoundError: de/schlichtherle/license/LicenseManager。

原因:truelicense-core的scope写成了provided,IDE 运行时有 classpath 兜底所以不报错,fat jar 打包时不会把这个依赖放进去,上线就找不到了。

解决:独立部署的项目把scope去掉或者改成compile;如果是父 POM 统一管理的企业项目,和架构组确认运行环境是否真的预置了这个库,多数情况下该改的是scope而不是给每台机器装库。

5.5 现象五:License 明明没过期,系统提示「已过期」

现象:许可expiryTime设的是 2026 年底,但 2025 年中就报过期,运维查了半天没找到原因。

原因:服务器时钟被 NTP 校时往回拨过,或者时区设置成了 UTC 而许可签发时用的是 GMT+8,issuedTime被解析成未来时间,verify判定notBefore未到,等同于校验失败。

解决:签发和校验两侧统一用GMT+8时区,@JsonFormat的timezone属性必须一致;服务器侧上 NTP 并设置makestep防止大跨度回拨;校验逻辑里对时钟偏差超过阈值的场景记录独立日志,不要和「过期」混在同一个异常里,方便排障。

6. 进阶:有效期、指纹容错与灰度授权三者怎么一起调

真实项目里 License 很少是一刀切的「一年有效期」。我给客户做过的方案里,试用期、正式期、扩容过渡期通常分三段签,每段expiryTime不同、consumerAmount不同,靠同一个subject和私钥签发,客户端拿到哪一段就用哪一段。这里的关键在于LicenseCheckModel要做容错,而不是简单做「一模一样」比对。

容错的核心思路是给每个维度设一个「匹配阈值」:IP 和 MAC 用集合交集判断,交集非空即通过;CPU 序列号和主板序列号必须精确匹配;如果客户机器换了主板但 CPU 没换,可以给一个「允许主板空值 + CPU 匹配」的降级通道,通过配置项控制是否开启。这样既能拦住整机复制,又不会因为一次网卡更换就把正常客户卡死。

灰度授权的另一个常见做法是给description加上业务标识,客户端读出description之后按前缀做分流,比如trial:前缀只放行只读接口、prod:前缀放行全部接口。这个字段本身带签名保护,客户改了就破签,所以可以作为业务分流的可信来源。

public boolean matches(LicenseCheckModel actual, LicenseCheckModel expected) { // CPU 和主板精确比对 if (!Objects.equals(normalize(actual.getCpuSerial()), normalize(expected.getCpuSerial()))) { return false; } // 主板允许按配置降级 if (!allowEmptyMainBoard && !Objects.equals(normalize(actual.getMainBoardSerial()), normalize(expected.getMainBoardSerial()))) { return false; } // IP 和 MAC 用交集 return hasIntersection(actual.getIpAddress(), expected.getIpAddress()) && hasIntersection(actual.getMacAddress(), expected.getMacAddress()); } private String normalize(String value) { return value == null ? "" : value.trim().toUpperCase(); }

allowEmptyMainBoard这个配置项是从一次事故之后加的——客户换了主板但没换 CPU,本来重启就能解决,结果因为主板序列号严格匹配,客户侧停机了半天。从那以后我每次签正式许可之前,都会强制走一遍「本机自测 → 换网卡自测 → 换主板自测」三段验证,把三种边界都跑一遍,再交到客户手里。签许可这件事没有后悔药,宁可签发前多花二十分钟,也别让客户在机房陪你一起查日志。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询