做Java商业软件的人,迟早要面对同一个问题:程序是卖出去了,但怎么保证只有买它的人能用?试用期到了怎么自动停?客户把安装包转手给另一家公司,你该怎么止损?我去年给公司的Java客户端系统补授权模块时,一开始也动过自己手写校验逻辑的念头,后来调研到TrueLicense才发现,Java工程里的License授权机制早就有了一套现成的、不依赖联网的成熟方案。所谓License,本质就是一张数字授权凭证:开发商用私钥签发,程序内置公钥校验,离线环境也能跑通。这篇文章就以TrueLicense为工具,把从密钥生成、许可证签发,到Java程序内置校验的完整链路拆开讲清楚,同时把我实际踩过的坑一并交代出来,适合正在做Java商业软件授权模块的同学参考,也适合想在项目里快速接入License机制的开发者。
1. 为什么需要自研License授权:业务场景与场景拆解
1.1 商业软件分发中License要解决的三个核心问题
先说说我遇到的真实场景。我们有一款面向企业客户部署的Java应用,客户环境基本都在内网,完全没法依赖云端激活。最初没有License机制,程序让销售拷给客户就完事了,结果第二年客户B拿着从客户A那儿刻录的光盘来找我们做售后。这种哑巴亏,一次就够让你下决心上授权机制。
细拆下来,License要解决的核心问题就三条:
- 防止拷贝复用。Java字节码天然容易被复制,但授权文件不能。只要授权校验严格,拷走的程序在别的机器上要么启动不了,要么功能受限。
- 控制试用期与续费节奏。销售说免费试用30天,技术上就要保证第31天真的用不了,否则"试用"就成了永久免费。
- 授权对象可审计、可追溯。授权给了哪个客户、几台机器、什么时候到期,这些信息要么管理员能查,要么程序内部能读到,出了问题能追溯到源头。
这些问题不解决,商业软件卖得越多越亏。而解决它们的办法,就是一套可签、可验、可失效的License机制。
1.2 TrueLicense与硬件狗、联网激活的选型对比
做Java授权方案,比较成熟的可选项大致就这几种:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 加密狗(硬件) | 破解难度高、一机一狗 | 成本高、物流麻烦、驱动兼容问题多 | 高价工业软件、专业设计工具 |
| 联网激活 | 可控性强、能收集使用数据 | 依赖服务器,内网断网就没法用 | 纯在线SaaS、有强制联网条件的软件 |
| 自研证书+签名校验 | 离线可用、成本低 | 所有逻辑都要自己写,细节容易漏 | 有研发资源、需要完全可控的团队 |
| TrueLicense离线授权 | 离线可用、API成熟、签名机制可靠 | 防破解强度依赖私钥保管 | 内网部署的商用Java应用 |
很多做Java商业软件的项目组,最后都选了TrueLicense这条路。原因很朴素:它把证书生成、私钥签名、公钥校验、过期判断这些通用工作封装成了Java类库,我们只需要准备密钥库、填参数、调API,就能跑通一套完整的离线授权体系,不用自己从零实现RSA签名和证书管理。
1.3 授权粒度与生命周期怎么定
动手写代码之前,先想清楚你要卖什么、怎么卖。最常见的授权粒度有三种:
- 按时间授权:试用30天、按年订阅,到期自动失效。
- 按用户数/使用量授权:比如授权50个用户,多了就不让登录,对应TrueLicense里的consumerAmount字段。
- 按机器绑定授权:在一机一码的场景下,把机器的硬件指纹写进授权文件,校验时读取本机指纹比对,防止一个License多台机器用。
授权对象的生命周期通常分为签发、发布、安装、校验、到期续期五个阶段。开发商用自己的私钥签发license文件并交付给客户;客户把文件放到指定位置;程序启动时加载并校验;到期后要么停用,要么让客户付费后重新签发一份覆盖原文件。理解了这张大图,后面每段代码在干什么,心里就有数了。
2. 加密原理先行:公钥与私钥是如何让License不可伪造的
2.1 一次签名、处处验证
TrueLicense的核心原理,一句话说就是私钥签名、公钥验签。这里可以用一个生活化的类比:开发商手里有一枚原子印章(私钥),签发License时往授权信息上盖一个章;程序内部不存印章,只存一枚验印的放大镜(公钥)。任何人改动了授权信息里的一个字,盖章的纹路就对不上,程序直接判定无效。因为非对称加密决定了公钥无法反推私钥,所以即使客户把程序反编译了,把公钥拿出来了,也造不出一份合法的授权文件。
落到技术细节上,签发流程大致是:把授权内容对象序列化,计算摘要,用私钥对摘要做签名,然后连同公钥证书一起打包成License文件;校验流程则是用公钥解开签名、比对内容摘要,再检查有效期。这里面每一步都由TrueLicense的API封装好了,我们不需要自己写加密算法。
2.2 License文件到底是个什么东西
很多人第一次接触License,会以为它是一个不可读的加密文件。实际上,TrueLicense生成出来的License文件,本质是一个ZIP/JAR结构的容器。扩展名虽然叫.lic,你可以试着用解压工具打开它,里面能看到授权信息、签名数据、公钥证书等条目。
这个认知很重要,因为它能帮你正确理解防篡改机制:License不是不能改,而是改了就校验失败。有人会想"我把过期时间改成2099年再封回去不就行了",但任何内容的修改都会让签名校验挂掉,程序依然能识别出来这份授权被动过手脚。安全性不在于文件打不开,而在于签名不可伪造。
2.3 Subject和Extra决定授权的业务边界
TrueLicense里有两个参数,决定了一份License的"业务边界"。
Subject,可以理解为这份License是授权给哪个应用或哪个模块的。签发和校验收到的subject必须完全一致,否则校验直接失败。我见过不少新手在这里栽跟头——本地生成本地校验没问题,换一个工程就报错,一查两个地方的subject大小写不一样。
Extra,则是一个Map<String, String>,用来承载业务上的附加信息。比如客户编号、项目经理姓名、机器指纹等。校验通过后,这些信息能从LicenseContent里原样读出来,非常实用。
另外还有Holder(持有人)、Issuer(签发人)、ConsumerType(使用类型)、ConsumerAmount(使用数量),这些字段共同拼出授权信息的主体部分,也都会被序列化和签名保护,改不了。
2.4 失效机制:时间也不是改一改就完事
LicenseContent里有Issued(生效时间)和NotAfter(到期时间)两个字段。真实验证时,程序会检查当前系统时间是否落在[生效时间,到期时间]这个区间内,不在区间内就判定为授权无效。
所以"时间"是License机制里最依赖本地环境的一个变量,也正因如此,后面实战部分我们会专门做防时间篡改的策略,只校验一次是不够的。
3. 环境准备:用KeyTool生成密钥对并搭建License生命周期
3.1 三个keytool命令搞定密钥对与公钥库
要开始实战,第一步是准备密钥。JDK自带的keytool工具就够了,不需要额外引入任何加密软件。整个过程就三条命令。
# 第一步:生成私钥库,有效期设为3650天 keytool -genkeypair -keysize 2048 -validity 3650 \ -alias privateKey \ -keystore privateKeys.keystore \ -storepass storepass -keypass keypass \ -dname "CN=license-admin, OU=R&D, O=Example Inc, L=Beijing, ST=Beijing, C=CN" # 第二步:从私钥库导出公钥证书 keytool -exportcert -alias privateKey \ -keystore privateKeys.keystore \ -storepass storepass \ -file certfile.cer # 第三步:建立公钥库,导入公钥证书(将来内置到Java程序中) keytool -importcert -alias publicCert \ -file certfile.cer \ -keystore publicCerts.keystore \ -storepass storepass这三条命令里面有几个参数需要理解:
-keysize 2048:RSA密钥长度,2048位在目前的安全场景下是底线。-validity 3650:密钥对本身的有效期,单位是天,3650天就是10年。这个时间要覆盖你卖License的周期,建议给足。-storepass和-keypass:前者保护密钥库这个文件,后者保护库里的密钥条目。两者可以一样,但代码里必须分清。-alias:每个密钥在库里的名字,后面代码加载时要用。-dname:证书里的身份信息,会写进证书中,可以理解为"这张证书是谁的"。
第1步生成的是私钥库,必须由开发商自己保管;第3步生成的是公钥库,里面只有公钥证书,没有私钥,可以放心打进程序的resources里。流程背后的逻辑是:签发用私钥,校验用公钥。客户手里永远只有公钥和License文件,拿不到私钥,也就无法伪造授权。
3.2 密钥文件的保管规范
这里必须强调一下文件管理的红线。privateKeys.keystore一旦泄露,你的整套授权体系等于裸奔——任何人都能用它签发合法License。所以这个文件:
- 不允许进入Git仓库,哪怕私有的也不行;
- 不允许打进安装包、Docker镜像、交付物里;
- 只放在授权中心/运营管理端的专用目录,由专人管理。
公钥库publicCerts.keystore则相反,它要打进Java工程的resources或classpath里,每个程序实例都需要它来校验License。我把这个原则总结成一句话:私钥藏起来,公钥放出去,License单独发。
3.3 授权生命周期管理
从运营视角看,一套License机制要管理的生命周期是清晰的:
| 阶段 | 动作 | 产物 | 负责方 |
|---|---|---|---|
| 签发 | 运营人员填参数、调生成接口 | license.lic | 开发商 |
| 发布 | 通过邮件/交付物发License给客户 | license.lic | 销售或实施 |
| 安装 | 客户将License放到指定目录 | license.lic | 客户/实施人员 |
| 校验 | 程序启动时加载并验证签名、有效期 | 校验结果 | 程序内 |
| 续期/吊销 | 重新生成新License,覆盖旧文件 | 新license.lic | 开发商 |
注意,续期不是"修改原License的过期时间",而是重新生成一份新的License,覆盖掉客户目录下的旧文件。因为旧文件的内容一旦有任何改动,签名校验就会失败,所以正确的续期姿势就是重签。
4. 实战撸码:License生成端核心实现
4.1 引入依赖
生成端和校验端最终要集成进两套代码里:一套是公司的授权管理后台,一套是你的Java商业应用。但底层依赖是同一个Maven坐标。
<dependency> <groupId>de.schlichtherle.licensing</groupId> <artifactId>truelicense</artifactId> <version>1.82</version> </dependency>1.82是目前网上资料最多、踩坑经验最全的版本。如果你的项目是JDK 8及以上,一般不会有兼容性问题。它内部会传递引入commons-logging、commons-codec等库,如果和项目已有依赖冲突,后面第6章给排查办法。
4.2 定义LicenseCreatorParam参数实体
生成License,先定义一个参数实体,把运营人员要填的信息收口在一个地方。
public class LicenseCreatorParam { /** 授权对象,即你的应用名,校验时必须完全一致 */ private String subject; /** 私钥库文件路径 */ private String privateKeysStorePath; /** 密钥库口令 */ private String storepass; /** 密钥访问口令 */ private String keypass; /** 私钥别名 */ private String alias; /** License输出路径 */ private String licensePath; /** 生效时间 */ private Date issuedTime; /** 到期时间 */ private Date expiryTime; /** 使用类型 */ private String consumerType = "User"; /** 使用数量 */ private int consumerAmount = 1; /** 持有人 */ private String holder; /** 签发人 */ private String issuer; /** 附加业务信息,比如客户编号、机器指纹 */ private Map<String, String> extra = new HashMap<>(16); // 省略 getter / setter }字段含义在注释里写得很清楚,实际使用中你还会发现这个实体的价值:把授权配置从代码里抽离出来,交给运营去填。签发界面或者配置文件里填的内容,最后都会映射到这个对象上。
4.3 自定义LicenseManager并暴露create/verify
接下来定义一个CustomLicenseManager,继承TrueLicense的LicenseManager。在部分版本中,create和verify方法是protected的,子类里把它们重写为public,外部代码才能直接调用。
import de.schlichtherle.license.LicenseContent; import de.schlichtherle.license.LicenseException; import de.schlichtherle.license.LicenseManager; import de.schlichtherle.license.LicenseParam; import java.io.File; public class CustomLicenseManager extends LicenseManager { public CustomLicenseManager(LicenseParam param) { super(param); } @Override public synchronized byte[] create(LicenseContent content, File licenseFile) throws LicenseException { return super.create(content, licenseFile); } @Override public synchronized LicenseContent verify(File licenseFile) throws LicenseException { return super.verify(licenseFile); } @Override public synchronized LicenseContent verify(byte[] key) throws LicenseException { return super.verify(key); } }这段代码本身很简单,它的作用就是"把能力暴露出来"。核心逻辑全在父类里:签名、打包、校验、过期判断,都由TrueLicense按标准流程执行。
4.4 生成License的完整代码
生成端服务类的核心逻辑如下:
import de.schlichtherle.license.DefaultLicenseParam; import de.schlichtherle.license.KeyStoreParam; import de.schlichtherle.license.LicenseContent; import de.schlichtherle.license.LicenseManager; import de.schlichtherle.license.LicenseParam; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.util.HashMap; public class LicenseCreatorService { public boolean generate(LicenseCreatorParam param) { try { // 1. 封装密钥库访问参数,生成端指向私钥库 KeyStoreParam keyStoreParam = new KeyStoreParam() { @Override public String getAlias() { return param.getAlias(); } @Override public String getStorePwd() { return param.getStorepass(); } @Override public String getKeyPwd() { return param.getKeypass(); } @Override public InputStream getStream() throws java.io.IOException { return new FileInputStream(param.getPrivateKeysStorePath()); } }; // 2. 创建License管理对象 LicenseParam licenseParam = new DefaultLicenseParam( param.getSubject(), new HashMap<>(16), keyStoreParam); LicenseManager manager = new CustomLicenseManager(licenseParam); // 3. 组装授权内容 LicenseContent content = new LicenseContent(); content.setSubject(param.getSubject()); content.setHolder(param.getHolder()); content.setIssuer(param.getIssuer()); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setIssued(param.getIssuedTime()); content.setNotAfter(param.getExpiryTime()); content.setExtra(param.getExtra()); // 4. 生成License文件 manager.create(content, new File(param.getLicensePath())); return true; } catch (Exception e) { // 记日志:生成License失败 e.printStackTrace(); return false; } } }这段代码的关键点在于:
- 第1步里,
getStream()返回的是privateKeys.keystore的输入流,这就是签发端的核心身份凭证; - 第2步,
DefaultLicenseParam的三个参数分别是subject、extraMap和KeyStoreParam,注意subject在这里就定下来了; - 第3步,LicenseContent里的字段会参与序列化和签名,这就是"盖到授权信息上的章";
- 第4步,
manager.create(content, licenseFile)执行签名与打包,输出License文件。
调用的时候,运营人员只需要构造一个LicenseCreatorParam,设置好授权对象、客户名、到期时间,调用generate()即可。生成成功后,把license.lic发给客户,签发工作就结束了。
5. 实战撸码:Java工程内置校验与授权信息读取
5.1 校验端的参数实体与公钥库加载
授权模块做成之后,下一步就是把它嵌进真正的Java商业应用里。校验端需要一个独立的参数实体,它和生成端的差异在于:指向公钥库,而不是私钥库。
public class LicenseVerifyParam { /** 授权对象,必须与生成时一致 */ private String subject; /** 公钥库路径,支持 classpath: 前缀 */ private String publicKeysStorePath = "classpath:publicCerts.keystore"; /** 密钥库口令 */ private String storepass = "storepass"; /** 公钥库没有独立keypass,填充storepass即可 */ private String keypass = "storepass"; /** 导入公钥证书时的别名 */ private String alias = "publicCert"; /** License文件路径 */ private String licensePath = "/data/license/license.lic"; // 省略 getter / setter }特别说一下publicKeysStorePath。公钥库一定要打进程序的resources目录,所以校验代码里最常见的问题是new FileInputStream("publicCerts.keystore")根本找不到文件——因为它在classpath里,不在文件系统里。处理办法是支持一个classpath:前缀。
5.2 校验License的完整代码
校验服务类的实现如下:
import de.schlichtherle.license.DefaultLicenseParam; import de.schlichtherle.license.KeyStoreParam; import de.schlichtherle.license.LicenseContent; import de.schlichtherle.license.LicenseManager; import de.schlichtherle.license.LicenseParam; import org.springframework.core.io.ClassPathResource; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.util.HashMap; public class LicenseVerifyService { public LicenseContent verify(LicenseVerifyParam param) { try { // 1. 公钥库访问参数 KeyStoreParam keyStoreParam = new KeyStoreParam() { @Override public String getAlias() { return param.getAlias(); } @Override public String getStorePwd() { return param.getStorepass(); } @Override public String getKeyPwd() { return param.getKeypass(); } @Override public InputStream getStream() throws java.io.IOException { return loadStream(param.getPublicKeysStorePath()); } }; // 2. 创建校验专用的LicenseManager LicenseParam licenseParam = new DefaultLicenseParam( param.getSubject(), new HashMap<>(16), keyStoreParam); LicenseManager manager = new CustomLicenseManager(licenseParam); // 3. 执行校验 return manager.verify(new File(param.getLicensePath())); } catch (Exception e) { // 校验失败,记日志并返回null e.printStackTrace(); return null; } } private InputStream loadStream(String path) throws java.io.IOException { if (path.startsWith("classpath:")) { String resource = path.substring("classpath:".length()); return new ClassPathResource(resource).getInputStream(); } return new FileInputStream(path); } }manager.verify(licenseFile)这一步才是校验的核心。TrueLicense会做三件事:用公钥解开签名、比对内容摘要、检查时间窗口。只有三者全部通过,才会返回LicenseContent对象;任何一环不通过,直接抛异常。所以,校验返回非空,就代表这份License是合法且未过期的。
5.3 启动时校验 + 定时复验:防止时间回拨
很多人把License校验写进ApplicationRunner或main方法里,启动时校验一次就以为万事大吉。这里有一个我实际碰到过的漏洞:客户把服务器系统时间回调两三年,你的"过期校验"就形同虚设了。
解决方案很朴素:启动时校验一次,同时开启一个后台定时任务,定期复验。
import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class LicenseWatchdog { private final LicenseVerifyService verifyService; private final LicenseVerifyParam verifyParam; public LicenseWatchdog(LicenseVerifyService verifyService, LicenseVerifyParam verifyParam) { this.verifyService = verifyService; this.verifyParam = verifyParam; } public void start() { ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "license-watchdog"); t.setDaemon(true); return t; }); executor.scheduleAtFixedRate(() -> { LicenseContent content = verifyService.verify(verifyParam); if (content == null) { // License失效:停止核心服务、弹出提醒、限制功能…… // 按你业务的需要去处理 } }, 1, 1, TimeUnit.HOURS); } }定时复验的核心目的,是防止"启动时合法,之后系统时间被修改"这种情况。哪怕客户启动后又把时间调回2020年,最迟一小时后定时任务就会再次校验并踢掉服务。
另外还可以加一道相对时间防护:本地记录"最近一次成功校验的时间戳",如果当前时间比这个时间戳还早超过5分钟,说明系统时间被明显回拨过,可以直接拒绝服务。这个思路实现起来很轻,但能把"时间篡改"的路堵掉大半。
5.4 读取授权信息并展示给客户
校验通过时,verify返回的LicenseContent对象里包含了完整的授权信息,别浪费。比如产品里"关于"页面完全可以展示这些内容,也可以做到期提醒。
LicenseContent content = verifyService.verify(verifyParam); if (content != null) { // 客户名称 String holder = content.getHolder(); // 签发人 String issuer = content.getIssuer(); // 生效时间 java.util.Date issued = content.getIssued(); // 到期时间 java.util.Date notAfter = content.getNotAfter(); // 剩余天数换算 long days = (notAfter.getTime() - System.currentTimeMillis()) / (1000L * 3600 * 24); // 附加信息,比如客户编号 String customerNo = content.getExtra().get("customerNo"); // 剩余不足7天时,可以做续费提醒 if (days <= 7) { // 提示客户联系销售续费 } }这个信息读取能力还有个实用场景:当你需要排查某台机器的授权状态时,不用翻日志,直接把License文件放进去跑一次校验,就能看到授权给了谁、什么时候过期。
6. 我踩过的坑与定位思路:从参数错位到环境差异
6.1 Subject不一致:本地好好的,换项目就报错
我印象最深的一个坑出现在联调阶段:签发端在管理后台生成了License文件,本地用一个Demo工程校验通过,但真正集成进商业应用后,怎么都校不过。排查链路是这样的:
- 先看异常信息,报错核心关键词是
subject,提示授权对象不匹配; - 于是去比对签发参数和校验参数里的subject,发现一个是"order-system",一个是"ordersystem",中间多了个横线;
- 改回一致后再校验,立刻通过。
这个问题的隐蔽之处在于,Subject并不参与加密算法,你很容易以为它不重要。但它就是一把钥匙的齿形,差一毫米都开不了门。我的建议很简单:把Subject抽成全局静态常量,签发端和校验端引用同一个常量值,从源头杜绝拼写不一致。
6.2 时间被改引发的授权失效与失效后复活
客户环境里系统时间是不可控的,我遇到过两种相反的坑:
第一种,客户为了白嫖,把系统时间调到2030年,License反而一直在有效期内;第二种,IT部门做时钟同步,服务器时间往回拨了几小时,License在重启后无故失效,故障单堆了一堆。
前者用定时校验+本地时间戳防护解决,后者则需要你容忍一定的时间偏移。我的做法是:本地记录上一次成功校验的时间戳,只要当前时间比上一次成功校验时间早超过5分钟,就判定时间异常。这样的话,NTP同步导致的几秒、几分钟偏移不会被误伤,而几十年的回调会被立刻识破。
6.3 Keystore打包与路径问题
生成端和校验端路径处理方式是不同的。生成端在管理后台运行,私钥库一般是绝对路径,new FileInputStream没问题。但校验端在Java应用里,公钥库经常被打进jar包,此时new FileInputStream("publicCerts.keystore")一定会失败。
排查这个问题时,我先检查了target目录下有没有公钥库文件,确认在resources里存在;再检查代码里的路径,发现用的是文件路径而不是classpath路径。改成classpath:publicCerts.keystore并用ClassPathResource加载后,问题消失。
建议校验端工具类统一处理这两种路径:支持classpath:前缀,也支持绝对路径。这样打包成jar、war、或者外挂配置文件三种部署方式都能跑。
6.4 多实例部署与机器绑定问题
我最初以为License天然与机器绑定,后来测试发现,同一个license.lic文件放在两台不同的服务器上,都能通过校验。原因在于TrueLicense默认只校验签名和有效期,并不采集硬件信息。
如果你的业务确实要"一机一码",可以在签发时把机器特征写进LicenseContent的extra里,比如CPU序列号、主板序列号、磁盘序列号等;校验时读取本机指纹,和extra里的值做比对,不一致就拒绝。需要注意的是,机器指纹计算不要只依赖MAC地址,因为MAC可以被修改。我一般推荐综合CPU、主板、磁盘三个维度算一个指纹串,命中率更高,也更难被模拟。
6.5 依赖冲突与版本选择
最后一个坑,也是集成类库最常见的问题:truelicense依赖了commons-codec、log4j等库,很容易和项目已有依赖冲突,导致启动报一堆ClassNotFoundException或NoSuchMethodError。
排查方式很简单,用Maven命令看依赖树,找到冲突的传递依赖,然后在pom里排除,再引入项目里兼容的版本:
<dependency> <groupId>de.schlichtherle.licensing</groupId> <artifactId>truelicense</artifactId> <version>1.82</version> <exclusions> <exclusion> <groupId>log4j</groupId> <artifactId>log4j</artifactId> </exclusion> </exclusions> </dependency>这里也顺带提一点版本选择的经验:truelicense的1.x和2.x API差异比较大,网上大部分资料以1.82为主。如果项目里已经有其他组件强制依赖2.x,你需要参考2.x的官方文档重新适配,不要盲目升版本。选定版本后,把truelicense相关的API调用封装在自己模块里,即便以后升级,也只改封装层,不动业务代码。这一点我是在一次升级踩坑之后才真正体会到的——能用一层壳解决的问题,永远不要直接穿透到业务代码里。