商用软件如果不做授权机制装包一散出去基本等于开了一个不限速的下载站。很多Java团队做完产品、进入交付阶段时都会碰到同一个问题客户怎么在约定时间内使用、只能在指定机器上运行、到期之后自动停用——这些需求不是一句你手动验收一下就能糊弄过去的。Java生态里做授权控制的成熟方案不多TrueLicense算是最常被提到的那一个。它做的事情其实可以拆成两半一是用私钥生成License文件二是在Java应用里用公钥校验这份文件。核心依赖是非对称加密也就是KeyStore里那一对RSA密钥校验端只保留公钥私钥牢牢待在你们自己的授权服务器上。这样就算客户把License文件翻来覆去研究也没法伪造一个永久版本。这篇文章适合给已经有Java Web开发经验、准备给自己的商业项目加授权机制的团队。我会把原理讲透License为什么验得出来真伪、公钥私钥怎么配合、机器码绑定是怎么回事、Spring Boot里怎么落地以及我实际部署时踩过的几个坑。1. 整体设计与方案选型的思路1.1 为什么简单的日期校验不靠谱很多人第一反应是直接存一个到期时间在Java代码里判断一下当前时间是否超过截止日期就完事。但稍微有点安全意识的人都清楚这套方案基本等于裸奔。Java字节码很容易被反编译你写在代码里的时间判断逻辑用反编译工具看一眼就能定位到要么直接绕过要么篡改常量更别说客户还可以把数据库恢复到早期时间点人为制造时间倒流来绕过限制。所以授权机制真正要解决的是不可伪造和不可篡改。不可伪造意味着不是谁拿文本编辑器敲一个文件就能通过校验不可篡改意味着License文件里记录的客户名称、签发时间、到期时间、机器码这些信息只要被动过任何一个字节校验都必须失败。这两个需求恰好落在非对称加密的射程之内。私钥对内容做签名公钥对签名做验签验签结果不匹配就说明文件被改过或者压根不是你们签发的。1.2 TrueLicense在授权方案里的分工TrueLicense不是一个完整的授权业务系统它是一套围绕License文件的生成与校验的Java API库。它提供的能力分为两块LicenseCreator用于生成License文件运行在授权中心也就是你们公司内部LicenseManager / LicenseVerify运行在客户的应用里负责加载License文件、验签、判断有效期。它不绑定具体的加密算法实现默认走的是标准的RSA签名加SHA-256摘要这是Java原生环境里最稳妥的组合。密钥管理方式也比较标准私钥和公钥分别以KeyStore文件存储。这里要泼一盆冷水TrueLicense的官方文档相当简陋代码示例不仅少而且不少细节要靠自己摸索。网上流传的大多数示例都是基于它对LicenseManager这个抽象类的扩展实现的。所以这篇文章里我会先讲原理再给出真正能落地的封装方式并保留TrueLicense原生API的调用形态。2. 核心原理拆解License为什么能“验真”2.1 非对称加密的签名验签全流程在动手写代码之前先在脑子里把整条链路过一遍。你有一个privateKeys.store文件里面放着私钥。你需要从这个密钥库导出证书certfile.cer再把它导入到publicCerts.store。这个publicCerts.store会作为Java应用的资源文件部署到客户的机器上。签发License时私钥对客户名称 到期时间 机器码 其他授权信息做一次签名签名结果和原始内容一起写入.lic文件。客户应用启动时用publicCerts.store里的公钥对License文件做验签。只要文件内容被改动过哪怕一个字符验签就会失败。这个过程的日常类比是你有一枚刻着自己名字的章在合同上盖章后把合同交给对方对方手上只有你的章面印样。只要合同正文被涂改拿着印样一对立刻露馅。2.2 KeyStore、证书与密钥的完整链路这里有个很关键的细节很多代码示例里只有生成密钥库和验签的代码但不少人栽在keytool命令的各种参数上。完整链路分三步。第一步生成密钥对到私钥库keytool -genkeypair -keysize 2048 -validity 36500 -alias privateKey -keystore privateKeys.store -storepass storepass123 -keypass keypass123 -dname CNdev, OUdev, Odev, LBeijing, STBeijing, CCN第二步从私钥库导出证书keytool -exportcert -alias privateKey -keystore privateKeys.store -storepass storepass123 -file certfile.cer第三步把证书导入公钥库keytool -importcert -alias publicCert -file certfile.cer -keystore publicCerts.store -storepass publicstore123执行完这三条命令你会得到privateKeys.store、publicCerts.store和certfile.cer三个文件。其中privateKeys.store留在公司授权服务器上另外两个放进Java应用的资源目录。关于-validity 36500这个参数经常被忽略。默认情况下keytool生成的证书有效期只有90天如果在License有效期内证书先行过期验签就会直接失败。我曾经在一个项目里没指定有效期果然三个月后客户那边全部校验失败排查了半天才想起来证书过期这回事。2.3 授权内容的结构设计License文件里到底放什么决定了未来能做多细的授权粒度。我实际项目里使用了这些字段字段说明customerName客户名称通常填企业全称productName产品名称区分同一套系统不同产品线的LicenseissuedTime / expiryTime签发时间与到期时间consumerType授权对象类型比如User、CompanyconsumerAmount授权用户数或并发数extraMap类型可扩展机器码、模块开关、最大并发等signature上述所有内容的数字签名这里特别想提一下extra里的机器码。有很多教程没讲这块但实际上授权机制在2B场景里最常用的就是机器码绑定。机器码通常是CPU序列号、主板序列号、MAC地址拼接后做哈希得到的字符串。当License文件复制到其他机器时跑起来的机器码跟License里写的不一致验签就算通过也会在业务层的机器码比对环节被卡住。3. 实战实现从生成密钥到集成校验3.1 授权签发端的核心代码签发端需要做几件事加载私钥库、构建LicenseContent、调用LicenseManager生成License字节流、写入文件。先看核心参数模型。package参数模型可以用一个LicenseCreatorParam来承载整个授权业务的业务参数public class LicenseCreatorParam { private String customerName; private String productName; private Date issuedTime; private Date expiryTime; private Integer consumerAmount; private String consumerType User; private MapString, String extraInfo; // 省略getter/setter }再来看真正的签发逻辑。这里我封装成一个LicenseCreator类public class LicenseCreator { private final static X500Principal DEFAULTHOLDER_AND_ISSUER new X500Principal(CNdev, OUdev, Odev, LBeijing, STBeijing, CCN); public void createLicense(LicenseCreatorParam param) { try { KeyStore keyStore KeyStore.getInstance(KeyStore.getDefaultType()); try (InputStream ins new FileInputStream(privateKeys.store)) { keyStore.load(ins, storepass123.toCharArray()); } PrivateKey privateKey (PrivateKey) keyStore.getKey(privateKey, keypass123.toCharArray()); LicenseContent content new LicenseContent(); content.setHolder(DEFAULTHOLDER_AND_ISSUER); content.setIssuer(DEFAULTHOLDER_AND_ISSUER); content.setSubject(param.getProductName()); content.setIssued(param.getIssuedTime()); content.setExpiry(param.getExpiryTime()); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setInfo(param.getExtraInfo()); LicenseCreatorParam creatorParam new LicenseCreatorParam(); creatorParam.setPrivateKey(privateKey); LicenseManager manager new LicenseManager() { Override protected synchronized byte[] create(LicenseContent content, LicenseCreatorParam parameters) throws Exception { return super.create(content, parameters); } }; byte[] licenseBytes manager.create(content, creatorParam); try (FileOutputStream fos new FileOutputStream(license.lic)) { fos.write(licenseBytes); } } catch (Exception e) { throw new RuntimeException(License生成失败, e); } } }注意LicenseCreatorParam这个名字在TrueLicense原生包里已经存在所以如果你同时引入了自定义参数类别搞混包路径。LicenseManager.create内部会构建GenericCertificate并将私钥、密码装进去最终输出一个二进制License文件。我们不需要关心底层序列化细节但需要知道一点LicenseContent的字段越多将来校验时能获取的信息就越丰富。尽量用setInfo把业务字段塞进Map而不是新建一堆子类。3.2 应用侧校验核心代码应用侧要做三件事加载公钥库、读取License文件验签、校验到期时间和机器码。public class LicenseVerify { private static final String PUBLIC_CERT_STORE publicCerts.store; private static final String STORE_PASS publicstore123; private static final String ALIAS publicCert; public boolean verify(String licensePath) { try { KeyStore keyStore KeyStore.getInstance(KeyStore.getDefaultType()); try (InputStream ins LicenseVerify.class.getClassLoader() .getResourceAsStream(PUBLIC_CERT_STORE)) { keyStore.load(ins, STORE_PASS.toCharArray()); } Certificate certificate keyStore.getCertificate(ALIAS); PublicKey publicKey certificate.getPublicKey(); LicenseManager manager new LicenseManager() { Override protected synchronized LicenseContent verify(LicenseContent content) throws Exception { return super.verify(content); } }; LicenseContent content manager.verify(licensePath, new String[]{licensePath}, publicKey); // 到期时间校验 if (content.getExpiry() ! null content.getExpiry().before(new Date())) { return false; } // 机器码校验 MapString, String info content.getInfo(); if (info ! null info.containsKey(machineCode)) { String licenseMachineCode info.get(machineCode); String currentMachineCode MachineCodeUtil.getMachineCode(); if (!licenseMachineCode.equals(currentMachineCode)) { return false; } } return true; } catch (Exception e) { log.error(License验证失败, e); return false; } } }这里有个容易搞混的签名。网上有些版本直接写manager.verify(content)那通常是在自定义LicenseManager子类的方法签名不同时才成立。若调原生的verify(byte[] license, LicenseVerifyParam param)传入的参数顺序也要小心。我的习惯是直接把异常捕获住把最终结果简化成true/false。3.3 机器码采集与哈希生成机器码是授权系统里最能体现安全性的地方。我用了CPU序列号 主板序列号 第一块网卡MAC地址的组合取三者拼接后做SHA-256哈希。public class MachineCodeUtil { public static String getMachineCode() { String cpuId getCpuId(); String boardSerial getBoardSerial(); String mac getMacAddress(); String raw cpuId _ boardSerial _ mac; return DigestUtils.sha256Hex(raw); } }具体获取方式与操作系统强相关Windows上CPU序列号可以执行wmic cpu get processoridLinux上通常读/sys/class/dmi/id/product_uuid或/sys/class/dmi/id/board_serialMAC地址用Java自带的NetworkInterface.getNetworkInterfaces()遍历拿。Linux下读取product_uuid不一定有权限部分云服务器对该文件读取权限管控严格。我的做法是优先读取product_uuid读不到时改用board_serial再读不到就回退到只绑定MAC地址。这样既照顾了兼容性又不会因为一个文件读不到就直接导致License校验失败。3.4 Spring Boot集成启动校验加运行拦截在实际工程里License校验不能只做启动时一次。客户完全有可能改系统时间绕过启动校验所以更稳妥的做法是启动校验叠加运行期拦截。启动校验我建议放在ApplicationRunner里校验失败就直接抛异常让Spring Boot启动失败退出。Component public class LicenseInitRunner implements ApplicationRunner { Override public void run(ApplicationArguments args) throws Exception { LicenseVerify verify new LicenseVerify(); if (!verify.verify(license.lic)) { throw new RuntimeException(License验证失败应用无法启动); } } }运行期拦截则用HandlerInterceptor实现每次请求进入Controller之前校验一次失败则返回401或者自定义错误码。但每次请求都重新读License文件和做验签性能上确实有点浪费所以我加了一层缓存校验通过后缓存60秒过期后再重新校验。public class LicenseInterceptor implements HandlerInterceptor { private final LicenseVerify verify new LicenseVerify(); private volatile long lastCheckTime 0; private volatile boolean lastResult false; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { long now System.currentTimeMillis(); if (now - lastCheckTime 60_000) { boolean valid verify.verify(license.lic); synchronized (this) { lastCheckTime now; lastResult valid; } } if (!lastResult) { response.setStatus(401); return false; } return true; } }这个缓存策略在秒杀、高并发场景下尤其重要。如果每次请求都重新验签RSA解密在低配服务器上的耗时能到几十到上百毫秒流量一大CPU直接被打满。加缓存之后代价就只有最后一次验签时间加上一个状态判断。3.5 时钟回拨检测很多License方案没做时间回拨检测这是我在交付一个金融客户时被问到的客户那边运维把服务器时间调回三个月前License到期直接失效。应对思路其实很朴素把上次成功校验的时间记录到一个持久化文件中下一次校验时如果当前系统时间早于上次记录时间就说明系统时间被人为拨回去了。public class TimeCheckUtil { private static final String TIME_STAMP_FILE license.timestamp; public static boolean check() { long last readLastTimeStamp(); long now System.currentTimeMillis(); if (now last) { return false; } writeTimeStamp(now); return true; } }这个文件需要防止客户手动篡改。比较简单的做法是对记录值做一次摘要签名再存到文件里。如果客户改了文件内容摘要对不上一样会失败。当然如果对方技术足够强把整个校验逻辑都patch掉那就是另一场攻防战了普通商业项目到这一步已经够用。4. 常见问题与排查技巧实录4.1 License文件加载失败最常见的问题就是路径。常规启动方式下new FileInputStream(license.lic)是相对工作目录解析的当你用systemd或Docker启动应用时工作目录可能不是jar包所在目录文件就找不到了。建议把License路径做成外部配置项允许运维通过环境变量或启动参数指定比如-Dlicense.path/opt/app/license.lic。4.2 keytool证书过期导致验签失败这个坑我说过很多次。keytool默认证书有效期90天如果没指定-validity很可能License还没过期证书先过期了。不仅生成密钥对时要注意有效期导入证书到公钥库时也要留意证书本身的过期时间。建议统一使用-validity 36500也就是约100年有效期彻底避坑。4.3 时区不一致导致时间判断偏差签发端在本地用new Date()生成时间应用端在客户服务器上使用UTC时间如果没做时区转换到期判断可能出现几小时的偏差。我的做法是统一用UTC时间存储签发和到期时间。签发端生成时间时用LocalDateTime.now(ZoneOffset.UTC)校验端比较时也转为UTC再比对避免服务器时区对结果的影响。public static boolean isExpired(Date expiryTime) { Date nowUtc Date.from(Instant.now()); return expiryTime.before(nowUtc); }4.4 证书或密码被反编译泄露密钥库存放在jar包resource里如果把storepass写死在代码里客户反编译就能看到。公钥库本身泄露没那么严重但密码泄露会让客户有能力往公钥库里导入自己的证书从而绕过你们签发端的签名验证。建议公钥库的密码通过环境变量或启动参数传入至少不要明文硬编码。4.5 客户通过复制机器码绕过有人会说客户直接把机器码采集程序跑一遍然后把License文件里的机器码改成采集结果重新生成一份不就行了这里要明白申请机器码和签发License是分离的。客户机器需要先运行你们提供的机器码采集工具拿到机器码之后发给你们的商务或授权人员由你们在授权签发端重新生成匹配该机器码的License。客户如果只改License文件里的机器码字段而不重新拿私钥签名验签直接失败。这就是私钥不出门的价值所在。5. 实操总结与后续扩展建议5.1 我踩过的那几个坑我接手的第一个License项目当时图省事直接把私钥和公钥放在同一个目录里代码里也没做任何外部化配置。结果客户IT把整个部署目录打包发给了第三方做技术评估私钥也一起泄露后面的授权体系基本形同虚设。换到第二个项目我才彻底想明白私钥和公钥的生命周期、存储位置、权限控制从第一天就该像对待数据库密码一样对待。第二个印象深刻的坑是时机。当时有一个需求是客户管理系统在到期前30天启动时弹窗提醒续费。一开始我是在启动校验类里做的日期差判断上线之后发现因为一台服务器时钟被NTP纠正过日期差计算结果偶发为负导致客户一会儿看到弹窗一会儿看不到。后来改成以签发时间为基准、以签发时的当前时间为偏移做差值计算才真正稳定。5.2 方案还能怎么扩展如果你的业务需要更活的授权粒度可以在TrueLicense基础上做以下几层扩展在线验证应用启动时调用你们自己的授权服务接口拉取最新的授权状态支持试用期自动延长、按量付费、模块动态授权多License文件按模块拆License比如报表模块单独一份License客户买了哪个模块就下发哪份文件离线激活码给客户提供离线激活码激活码里包含机器码和授权信息由你们自己的离线签名工具生成容器环境适配在Kubernetes环境里机器码通常采集不到因为Pod的硬件信息与宿主机并不一致。这种情况下可以改用集群ID或Ingress Controller的客户端证书来做绑定。5.3 最后一件事密钥库密码别写死在代码里哪怕只有公钥库密码也别写死在代码里。你永远不知道客户那边会怎么反编译你的jar包。我的做法是启动参数传入或者从K8s的Secret注入环境变量。如果嫌每次启动都要指定麻烦也至少有做一个简单的环境变量映射别让明文密码躺在源码库和配置文件里。这套License机制并不复杂核心点其实就三个密钥分离、签名验签、业务层参数校验。把这三点想透代码怎么写都能落地想不透代码再漂亮也会在交付环节被客户或第三方逆向打穿。希望大家拿到这一套方案之后不只是复制粘贴跑通而是能理解每一步为什么要这么做然后在自己的产品里设计出更合适的授权策略。
