要塞攻击免广告版
210.79MB · 2025-10-31
我之前在一家公司做过一段时间自研产品的研发,做的项目类型比较多,既有内部系统,也有对外的产品,To C、To B 都接触过。
我们公司当时主推的产品大致分为两类:
政企客户有个非常典型的需求 —— 项目必须做完全私有化部署,运行在他们内网服务器上,且不能联网。
这就导致我们的产品一旦部署出去,整个运行环境都是离线的、我们完全不可控的。
那时候我们经常遇到一个很现实的问题:
听起来没啥毛病,但问题是:
产品要“试用”,就得部署到他们自己的服务器上。我们没有权限远程登录、也无法像 SaaS 模式那样通过账号系统来控制权限和使用时间。
但私有化部署就完全不同了:
产品安装包发出去,装在哪台服务器、运行多久、是否备份快照,我们都无法得知。一旦部署出去了,客户想继续用,不和我们联系也完全可以……
而从技术角度来看,如果我们不加任何控制,客户可以轻松地复制整个目录、还原镜像、无限期运行,这就等于是把我们的产品“白送”给了对方。
所以,为了避免这种情况,我们必须想办法控制“试用期”和“授权范围”,但又不能依赖网络,这时候就需要一套能在离线环境运行的授权机制,让我们的产品即使在“断网”的政企环境里,也能自动识别出:
我们后来采用了业内较为成熟且通用的解决方案 —— License 许可证授权机制,来实现对试用期、功能权限和设备环境的离线控制。
在政企客户这种私有化部署场景下,我们为什么会选择用 License 这种方式来做授权?除了防止白嫖,它其实还解决了很多实际的问题。
最基本的当然是 控制软件能不能用,比如只允许客户使用 30 天,时间到了自动失效;但这只是最基础的能力。一个完善的 License 授权系统,应该具备以下这些作用:
首先,它可以帮我们限制产品的使用范围。比如说这个 License 是给某个企业内部用的,那它就不能被拿去对外售卖;又比如 License 中可以写明这个软件是给单用户使用的,不能装在 10 台服务器上无限跑。
其次是控制功能开放的等级。有的客户只买了基础版,那我们只开放核心模块;买了高级版,才开放一些进阶功能,比如导出、接口调用、大数据分析等等。License 文件里会写明具体开了哪些功能,程序运行时就能据此判断给不给用。
第三点,当然就是控制使用时间。这也是我们场景中最常用的:先给客户一个 30 天试用版,时间到了,如果没续约就自动停用。授权时间既可以是固定日期,也可以按激活后多少天计算。
还有一个比较隐性的作用是知识产权声明。License 文件其实相当于告诉客户:这套软件是我们的资产,你有使用权,但不能乱改、乱发,尤其不能反编译、拿去商用二次分发。
除此之外,有些 License 还会约定一些“额外服务条款”,比如这个授权是否包含免费技术支持、是否能享受后续更新、是否满足某些行业的合规标准(比如涉密、审计、国产化要求等)。
说到底,License 就像是产品和客户之间的一份“合同”,不同的是它既能写在纸上(采购协议),也能写在代码里(授权逻辑)。对于我们来说,它最大的价值就是:
这也是为什么在私有化部署的场景下,我们最终选择用 License 来做离线授权的根本原因。
讲了那么多 License 的好处,那问题来了:
我们要做自己的 License 授权系统,怎么保证它足够安全、可控?
市面上确实有很多现成的 License 组件,但每个业务场景不一样,我们做的是离线的、私有化部署、政企客户使用,自己掌握可控性才最关键。
结合我们实际踩过的坑,下面这些功能几乎是“标配”,缺一不可。
最常见的就是客户说“我们试用一个月”,但是你不加时间限制,部署完了他想用多久就用多久。
我们可以在 License 中记录“授权起止时间”,程序启动时自动验证当前时间是不是在范围内。
比如:2025年9月1日 ~ 2025年9月30日,到期后软件直接拒绝运行。
而不是靠人工催客户:“哥,月底记得付钱哦”。
有些客户只买了基础版,功能就得收着点;买了专业版的,我们才开放更多能力,比如导出报表、第三方接口对接等。
License 文件里可以写明“哪些模块可以用”,比如:
程序里按模块检查 License 信息决定是否开放。
如果 License 文件不绑定设备,客户部署一套后可以无限复制,拿去别的服务器继续用。我们可以通过绑定机器信息(MAC 地址、CPU ID、主板序列号等)来控制授权。
比如:这个 License 只能部署在机器 A 上,换台机器运行就会校验失败。
如果 License 是明文 JSON,那客户可能直接改字段:
本来授权到 9 月 1 日,他一改变成 2099 年 1 月 1 日,就无限使用了。
所以我们必须加上数字签名(比如用私钥签名,公钥验证),只要内容被改动,签名校验就会失败,系统就直接报错拒绝启动。
Java 项目很容易被反编译,看源码后改一句逻辑:
// if (!isLicenseValid()) return false;
return true;
这不就直接绕过验证了吗?我们做了两件事来防止这个问题:
有些人会动歪脑筋:发现到期后不能用了?
那我直接把系统时间调回一个月前,是不是就能继续用了?
这时候我们需要“时间回拨检测”机制,比如:
万一客户把 License 文件删了、备份没做好、需要更换服务器,那是不是就完全无法用了?
我们需要设计一个安全的补发流程,比如:
这样客户体验也不会太差,销售流程也更可控。
这些功能听起来是不是觉得又多又麻烦,好像要做一大堆额外的事情?
其实不然——它们都是我们在实际交付过程中,一点点踩坑踩出来的经验总结。
我们可以把它理解为:我们不是在“给系统加功能”,而是在“把那些容易被钻空子的漏洞一个个堵上”。
否则,一套系统部署过去,客户动一动时间、反个编译、复制个 License 文件……
我们辛辛苦苦做的产品就变成“白送的”。
所以说到这儿,有些同学可能也好奇:
这些我们担心的问题,客户真的会动手去搞吗?他们是怎么搞的?我们又是怎么防住的?
如果你也做过私有化交付,那你肯定知道一件事:
客户从来不会直接告诉你“我们想白嫖” ,但他们的一些操作,总让人觉得哪里不太对劲 但是又很无语。
比如下面这些情况,你可能听说过,也可能亲眼见过——我们统统都踩过坑,也都一一做了应对。
有些系统的 License 文件是明文格式,比如一个 JSON 文件。里面可能写着这样的字段:
"expireDate": "2025-08-31"
客户拿到文件后,发现这个字段管控了过期时间,直接打开编辑器改成:
"expireDate": "2099-12-31"
保存、重启程序——居然真能用,等于绕过了付费续期。
这种情况我们是怎么防的?
我们当然不会把 License 文件做成“信任用户”的形式,而是采用非对称加密签名机制:
有的客户也不去动 License,而是动了更“隐蔽”的地方——服务器时间。
比如试用期是一个月,到期之后他们把服务器时间调回一个月前,再次运行程序。结果发现还能继续用,相当于“时间倒流白嫖”。
这种行为,在私有化部署中其实非常常见,程序一旦跑在客户自己的服务器上,时间由他们掌控,如果没有额外校验,很容易被绕过。
那我们要怎么防这种行为呢?
我们可以设计一套“时间回拨检测”机制,核心逻辑就是:
那具体要怎么实现呢?
程序运行时会读取一个本地的时间记录文件,比如叫 time-record.json。这个文件格式其实非常简单:
1693267200000:aa94dd0f3cbf3f7f3f5da3e3cba...
前半部分是时间戳(表示上次成功启动的时间),后半部分是签名(用密钥通过 HMAC 算出来的)。
我们每次启动程序时,做三件事:
time-record.json 中记录的“上次启动时间”这样一来,系统时间被回调就能被精准识别出来。
那如果客户直接删掉或者改这个记录文件呢?
我们也考虑了这种场景。
程序在处理 time-record.json 的时候,区分两种情况:
第一次启动(也就是文件不存在)
程序会从 License 文件中读取一个 firstUsedAt 字段,这个字段是在授权平台签发 License 时写入的,表示程序可信的第一次使用时间。
当发现记录文件不存在,程序就会判断:
如果当前时间和 firstUsedAt 接近(相差不超过 10 秒),说明是真正的首次启动,程序会自动生成记录文件
如果差距很大,说明文件被删除了,程序会直接终止启动,并提示:
时间记录文件缺失,疑似被篡改或删除
那这个签名有什么用呢?
为了防止客户手动改这个文件,比如把时间随便调成一个旧值,我们在保存时附带了一个签名。
签名是通过 HMAC 算法加密时间戳得来的,密钥是写在代码或配置文件里的。
程序每次启动时会:
我们最终要达到的效果
这些检测手段叠加在一起,还是能有效杜绝“通过时间回拨延长使用期限”的行为。
这种行为最常见了。
比如客户拿着我们授权的一份 License,部署在了他们主服务器上;过了几天,他们又偷偷把 License 文件复制到其他服务器上,又部署了一套。
他们以为只要是合法文件就能随便用,但实际上我们根本不会“只看文件”。
那这种我们是怎么防的?
我们在生成 License 文件时,会把当前机器的硬件指纹信息绑定进去,比如:
License 校验时会比对当前机器的硬件信息,一旦不匹配,就直接报错,拒绝加载。
这种“硬件绑定”机制相当于给 License 上了锁,即使复制文件过去,也不能直接复用。
Java 项目的另一个“硬伤”就是太容易被反编译了。
比如我们代码里写的:
if (!isLicenseValid()) return false;
客户只要用 JD-GUI、Jadx 之类的工具反编译一下,把这段逻辑手动改成:
return true;
重新编译一下,License 验证就形同虚设了。
那这种我们要怎么防呢?
首先,我们可以使用 ProGuard 或类似工具对代码做了混淆:类名、方法名都变成了 a、b、c,看都看不懂。
其次,我们还可以通过 XJar 加壳加密,把整个代码逻辑加密成 JVM 才能识别的格式,普通反编译工具完全打不开。
这就大大提高了反编译破解的门槛,让“绕过验证”变得几乎不可能。
有些客户会声称“License 文件不小心删了,能不能补发一份”,但实际可能是想额外搞一份 License,部署到其他机器上偷偷用。
我们是怎么防止这种“伪装找回”行为的?
每份 License 都带有唯一的授权编号(License ID),同时绑定了企业标识(如项目 ID、客户名称、税号、唯一 key 等)和机器指纹信息(CPU、MAC、主板等),并在签发时记录在案。
如果客户提出补发请求,我们系统可以根据 License ID 找到完整的授权记录,包括签发时间、绑定机器等内容,支持原样补发。
我们不会重新生成新的 License,而是将原始信息还原,确保补发的 License 与最初签发的完全一致。
一旦发现客户尝试伪造信息、重复申请 License,系统也能识别出来,并拒绝补发。
上面这些手段听起来可能有些“离谱”,但我们确实一个个都见过。
有时候客户不会告诉你他动了手脚,他只会说:“你们系统怎么又出错了?”你要是不提前做防护,就等于默认被白嫖。
所以我们做 License,不是为了“走个授权流程”,而是要真正让授权落到实处、落到细节,
做到哪怕被部署到了客户机房里,也必须在我们授权范围内使用,超出就立刻失效——这才算是把“授权机制”做明白了。
讲完了怎么防破解、防篡改,咱们再说一个在实际交付中非常常见、但又容易被忽略的问题——功能控制。
很多人一听 License,第一反应就是:不就是控制“能不能用”吗?
其实没那么简单,真正的核心用途之一,是控制“能用多少”“能用到什么程度” 。
我们在做私有化系统时就踩过这个坑。产品功能做得很全,总共十来个模块,但客户买的版本不同,使用权限也不同:
如果不做功能授权,那客户无论买哪个版本,打开都是全功能版本,这不就“买椟还珠”了吗?
类似下面这种版本功能对比,就是我们日常交付中经常要面对的问题:
所以说,License 不能只是“开门钥匙”,它还得是“权限清单” ,告诉程序我们到底有哪些功能能用,哪些不能动。
在给客户生成 License 文件时,我们会写进去一个“功能权限列表”。不同版本写法不一样,比如:
程序启动时会读取 License 文件,把这些权限加载成一个“当前授权功能列表”。接下来所有的功能调用、菜单渲染、接口请求,都会基于这份授权来判断。
比如:
LicenseContext.hasFeature("exportExcel")
advancedAnalytics 的权限,没有就直接隐藏按钮这样,客户看到的页面,和能点的功能,就会严格符合他们买的版本。该看见的能看见,没买的功能连入口都没有。
在实际业务中,不同项目的授权需求差别很大:
如果每次都靠手动勾选功能来生成 License,那工作量大、容易出错,也不利于后期维护。
所以我们专门设计了一个“功能模块包配置中心”,用来集中管理不同项目、不同版本对应的功能点。
配置功能模块包之前,代码层面要先准备好
前提是:功能点本身要先在后端代码里实现并接入授权判断逻辑。比如:
if (LicenseContext.hasFeature("exportExcel")) {
    // 执行导出逻辑
}
也就是说,功能点 key 是程序已经支持的开关项,配置中心只是控制开或关。后台不能随便写一个 key,前端和后端都识别不了。
核心理念:
exportExcel、logMonitor、crmIntegration 等;举个栗子:
比如某个“文档平台”项目,我们给它配置了两个模块包:
生成 License 的时候,如果选择的是“文档平台” + “高级版”,那后端会自动填充这些功能点,不需要前端再手动传哪些功能勾选了。
这样有几个明显的好处:
features 字段是自动生成,更安全也更准确。我们的表结构设计也非常简单:
我们使用了一张结构清晰的配置表 license_project_feature_config,用于记录“哪个项目的哪个模块包”对应了哪些功能点。示例如下:
| 项目 ID(project_id) | 模块包(module_name) | 功能 key(feature_key) | 展示名称(display_name) | 
|---|---|---|---|
| docx-platform | basic | exportExcel | 导出 Excel | 
| docx-platform | basic | dataImport | 数据导入 | 
| docx-platform | premium | logMonitor | 日志监控 | 
| docx-platform | premium | advancedAnalytics | 高级分析 | 
配置页面展示大概如下:
页面上提供下拉选择项目 → 展示模块包列表 → 每个模块包支持勾选功能点,提交后同步更新表中的配置。
功能模块包配置好之后,我们就可以正式生成 License 文件了。这个过程是在后台操作的,有一个专门的页面来填写相关信息并下载授权文件。
页面大概长这样(如下图):
整个页面其实就是一个表单,发证人需要按要求填写几个关键字段:
DOCX,后端生成 License 文件时会用这个标识拼接文件名和内容。TST),用来做 License 文件命名和归属识别。填写完这些信息后,点击“生成 License 文件”,系统就会根据选择的项目、客户、模块包和有效期,生成一个签名后的 .lic 授权文件,供客户下载使用。
后台接收到的请求参数大概是这样的:
{
  "projectId": "DOCX",
  "customer": "TST",
  "issueDate": 1753977600000,
  "expireDate": 1759248000000,
  "mode": "cluster",
  "features": {
    "exportExcel": true,
    "logMonitor": true
  },
  "boundMachines": [{
    "cpuSerial": "CPU123456",
    "macAddress": "00-14-22-01-23-45",
    "mainBoardSerial": "MB987654321"
  }]
}
参数说明如下:
| 字段名 | 含义 | 
|---|---|
| projectId | 项目标识,必须是系统内已注册的项目,比如 DOCX | 
| customer | 客户标识,表示授权给谁,比如 TST | 
| issueDate/expireDate | 授权的起止时间,单位是毫秒时间戳 | 
| mode | 部署模式,比如 standalone(单机)或cluster(集群) | 
| features | 功能权限列表,如果选择了模块包,会自动填充对应功能点;如果未启用模块包,则可能是手动填写或为空 | 
| boundMachines | 要绑定的服务器列表,每台服务器填三个硬件标识,用于做机器绑定校验 | 
生成成功后,License 文件会以如下格式命名:
DOCX-TST-202509-001.lic
命名规则中包含了项目、客户、时间等信息,方便后期归档和追踪。
这些生成记录我们也会保存到数据库中
为了方便后续的补发、续期、查询、审计等操作,我们在生成 License 文件时,也会把这次的授权信息一并保存到数据库中。
保存的内容和生成时填写的请求参数基本一致,比如:
我们通常会设计一张如 license_issue_record 的表,示意字段如下:
| 字段名 | 含义 | 
|---|---|
| id | 自增主键 | 
| project_id | 项目标识,例如 DOCX | 
| customer | 客户标识,例如 TST | 
| mode | 部署模式,例如 cluster | 
| issue_date | 签发时间(时间戳或日期) | 
| expire_date | 过期时间 | 
| feature_json | 功能点 JSON 字符串 | 
| bound_machines | 绑定的服务器信息(可 JSON 化存储) | 
| license_file_name | 生成的文件名,例如 DOCX-TST-202509-001.lic | 
| created_at | 创建时间 | 
| created_by | 操作人账号(可选) | 
这样做有几个用处:
这个记录保存动作一般和 License 文件的生成是绑定的事务操作,避免生成成功但记录丢失的情况。
在生成 License 之后,我们后台也提供了一个**“已签发 License 列表”页面,方便我们随时查看历史授权记录,并支持后续的续期和补发**操作。
这个页面主要用于展示和筛选历史发放过的 License 文件,字段包括:
| 字段 | 说明 | 
|---|---|
| License ID | 授权文件名称,一般由项目标识 + 客户标识 + 日期组成,例如 DOCX-HZSY-202509-001.lic | 
| 客户名称 | 被授权的客户公司名 | 
| 所属项目 | 授权项目的名称,如“文档平台”、“AI 文档助手”等 | 
| 模块包 | 授权时所选的模块包名称(如果该项目启用了模块机制) | 
| 签发日期 | License 的生成时间 | 
| 过期日期 | License 的有效期结束时间 | 
| 状态 | 当前是否过期,分为“正常”或“已过期” | 
| 操作 | 可执行“续期”或“补发”操作 | 
页面功能说明:
筛选功能:支持按客户名称 / License ID、所属项目、模块包、状态进行筛选,方便快速定位;
状态判断:系统根据 License 的过期时间自动判断状态,显示“正常”或“已过期”;
续期操作:
补发操作:
支持自动过期提醒和客户通知
为了提高授权运维效率,我们还接入了一些自动化能力:
Redis 缓存:将所有即将过期的 License 做每日缓存,方便定时任务快速扫描;
定时任务(任务调度) :每天凌晨定时扫描所有即将过期(如 15 天内)或已过期的授权;
消息队列:将扫描出的“待提醒客户”消息推入 MQ,由异步线程处理通知流程;
钉钉 / 飞书通知:
续期链接自动生成:
这套能力的核心目的就是:
让授权管理更自动、更及时,避免客户因 License 过期导致产品不可用,提升服务体验和授权可控性。
我们在落地过程中也踩过一些坑,这里整理几个建议供参考:
一开始我们直接用字符串判断,比如 if (feature == "exportExcel"),刚开始还行,但随着项目多了、功能变了,就容易出问题。建议统一搞个功能标识枚举类,或者放在配置文件里集中管理,这样后期修改和维护都更方便,避免到处硬编码。
功能限制不是后端一个人的事。后端负责做接口层的权限判断,前端也要根据权限控制按钮和菜单的可见性。推荐做法是:后端校验通过后,把授权功能点列表返回给前端,让它根据这些 key 控制界面展示。不要出现“后端说没权限,前端还能点进去”的尴尬情况。
有些客户一开始用的是“基础版”,用着用着想升级“高级版”或“旗舰版”。这时候我们需要能快速重新签发一个新的 License 文件,自动覆盖原有授权。同时,License 校验逻辑最好支持热更新,不依赖重启,能自动感知授权变更,体验才会好。
功能控制这件事,看上去不复杂,但真正落到业务上,其实是整个授权体系中非常关键的一环。
因为大多数客户不是“用不了”,而是“想多用一点”,想绕一绕、试试看。功能模块的精细控制,不只是防止被白嫖,更是我们产品分层销售、差异化定价的核心手段。可以说,功能模块是我们商业护城河的一部分,也是 License 授权机制最直接的变现出口。
前面我们已经讲清楚了什么是 License、它的作用、为什么要做授权控制,那这时候可能有同学会问——
我们这里来简单实现一个:从密钥生成、License 生成、程序校验全流程跑通。这里我会用 Spring Boot 项目来组织代码结构,结合离线场景考虑安全性,做到“能发出去、能验得了、还能防破解”。
在正式做 License 授权之前,我们需要先准备好一对“密钥”——私钥和公钥。
这个步骤就像是在盖章之前,先准备好印章和验章的工具。
我们这一步选用的是 keytool 命令行工具来生成密钥对。
这是 Java 自带的工具,不用写一行代码,直接在终端里敲命令就能搞定,特别适合 Java 项目。
尤其是如果我们的团队本身就习惯用 keystore 来管理证书,那这个方式会非常顺手。
本次演示环境是 mac + JDK 1.8。
做授权机制的时候,最核心的目的之一就是:
防止用户伪造 License 文件。
为了实现这个目的,我们需要借助非对称加密:
有了这一对密钥,客户端就能准确判断当前 License 文件是否真的是我们发的,别人即使照着格式伪造一个,也验不过。
用 keytool 生成密钥库(包含私钥和公钥)
我们可以用下面这条命令来生成一个密钥库,里面自动包含了一对密钥(私钥和公钥):
keytool -genkeypair 
 -alias privateKey 
 -keyalg RSA 
 -sigalg SHA1withRSA 
 -keysize 2048 
 -validity 3650 
 -keystore /Users/kaka/license_keys/privateKeys.keystore 
 -storetype JKS 
 -storepass pubwd123456 
 -keypass priwd123456 
 -dname "CN=localhost, OU=localhost, O=localhost, L=SH, ST=SH, C=CN"
当然我们也可以按需改一下路径和密码参数。建议先在本地建个目录来放密钥文件,例如:
mkdir -p /Users/kaka/license_keys
cd /Users/kaka/license_keys
执行成功后,会生成一个 privateKeys.keystore 文件。这个文件就是我们的密钥库,包含了:
关键参数解释一下
| 参数 | 说明 | 
|---|---|
| -alias privateKey | 密钥的别名,后面会用到这个名称来引用密钥 | 
| -keyalg RSA | 加密算法,必须手动指定 | 
| -sigalg SHA1withRSA | 签名算法,默认是 SHA1withRSA | 
| -keysize 2048 | 密钥长度,推荐 2048 位 | 
| -validity 3650 | 密钥有效期(单位是天,这里是 10 年) | 
| -keystore ... | 最终生成的密钥库文件路径 | 
| -storetype JKS | 指定使用 JKS 格式(兼容性好) | 
| -storepass | 密钥库的访问密码 | 
| -keypass | 私钥本身的访问密码 | 
| -dname | 描述密钥的归属,可以随便写,对功能没影响 | 
常见报错和处理方法
报错1:必须指定 -keyalg 选项
这个是因为有些新版 JDK 要求我们必须指定加密算法,否则无法生成。加上 -keyalg RSA 就可以。
报错2:提示 keypass 被忽略
这是因为 Java 9 以后默认使用 PKCS12 格式,而该格式本身不支持 keypass,所以我们设置了也没用。
我们在命令中通过 -storetype JKS 强制使用传统 JKS 格式,就能避免这个问题。
导出公钥证书(.cer 文件)
刚才生成的 keystore 文件里,公钥和私钥是放在一起的。
但在实际应用中,我们只希望客户端拿到 公钥,不能接触到私钥。
所以我们需要再做一步操作,把公钥从密钥库里导出来:
keytool -exportcert 
 -alias privateKey 
 -keystore privateKeys.keystore 
 -storepass pubwd123456 
 -file certfile.cer
执行成功后,会生成一个 certfile.cer 文件,这就是标准的 X.509 公钥证书文件。
这个文件里只有公钥,不包含私钥,可以放心地给客户端使用。
那这个证书文件有什么用?
.cer 公钥证书;如果我们还想更规范一点,可以把公钥单独导入到另一个 keystore 中(可选)
如果我们不希望客户端直接用 .cer 文件,而是希望它也加载 keystore,可以把公钥导入到一个新的 keystore 中:
keytool -import 
 -alias publicCert 
 -file certfile.cer 
 -keystore publicCerts.keystore 
 -storepass pubwd123456
这样我们就得到了一个只包含公钥的 publicCerts.keystore 文件,适合客户端统一管理、集中校验多个公钥的场景。
不过如果只是做简单授权验证,一个 .cer 文件就够用了,不用非得这样做。
到这里,我们已经完成了:
keytool 生成了一份密钥库,里面有私钥和公钥;.cer 文件,方便客户端使用;下一步,我们将用私钥来生成授权文件,也就是 License 文件本体。
完成了密钥对的准备工作之后,接下来就要进入真正的“授权阶段”了:
我们要用 私钥 来生成授权文件,也就是 License 文件。
这一阶段我们做了什么?
在我们实际系统中,License 的生成过程不需要手动敲命令,而是通过后台系统来完成的。我们只需要:
打开后台管理系统;
选择客户、所属项目、模块包、有效期等授权参数;
提交生成请求,后台会:
.lic 授权文件。整个过程对使用者是无感知的,底层则实现了一整套签名与验证机制,确保生成的 License 具备防篡改能力。
那为什么一定要“签名”?
假设我们只是生成一个普通的 JSON 文件,写上“这个客户可以用到 2025 年”,那任何人都能随便改内容、续费、甚至破解授权。
为了防止这种情况,我们需要引入数字签名机制:
这样一来就能确保:
License 文件长啥样?
我们可以把 License 文件理解成一份“授权说明书”,
它清晰描述了:客户是谁、授权时间、使用模式、有哪些功能权限、绑定哪些服务器等信息。
在我们的平台中,License 文件实际是一个结构化的 JSON 文件,大致像这样:
{
  "projectId": "DOCX",
  "customer": "TST",
  "issueDate": 1753977600000,
  "expireDate": 1759248000000,
  "mode": "cluster",
  "features": {
    "exportExcel": true,
    "logMonitor": true
  },
  "boundMachines": [
    {
      "cpuSerial": "CPU123456",
      "macAddress": "00-14-22-01-23-45",
      "mainBoardSerial": "MB987654321"
    }
  ]
}
字段解释如下:
| 字段名 | 说明 | 
|---|---|
| projectId | 项目标识,比如文档平台是 DOCX,CRM 系统可能是CRMN等 | 
| customer | 客户标识,用于识别授权对象,比如 “TST”、“BJXY” 等 | 
| issueDate | 授权起始时间,使用时间戳格式(毫秒) | 
| expireDate | 授权到期时间,时间戳格式(毫秒) | 
| mode | 授权模式,如 standalone单机 或cluster集群 | 
| features | 功能权限控制,按功能点设置是否启用 | 
| boundMachines | 授权绑定的服务器信息(CPU 编号、MAC 地址、主板序列号等) | 
那签名原理是怎么实现呢?
License 的签名逻辑很清晰:
signature 字段中;验证流程也很简单:
文件格式怎么选?
在我们平台中,License 文件默认使用 JSON 格式进行保存,后缀为 .lic。这是因为:
如果我们对接的是 Java 生态,当然也可以用 Properties 格式,配合 TrueLicense 读取。但本质逻辑一样:
先把内容转成稳定的文本格式,再进行签名写入,格式只是壳,签名才是关键。
前面我们已经通过 keytool 成功生成了一对密钥,现在就可以开始写真正的业务代码了。
但在写 License 的生成和验证逻辑之前,得先把整个项目跑起来,先搭好基本框架。
这一步的目标很简单:
先说明一下实际架构情况:
在真实的业务环境中:
也就是说:
生成和验证代码, 在实际项目中是分开的两个工程,甚至部署在完全不同的机器上。
一、我们都需要用到哪些接口?
虽然我们的授权系统看起来只是生成一个 .lic 文件,但在完整的业务链路中,其实会涉及到多个不同职责的接口。为了演示方便,我们这里统一放到了一个 Spring Boot 项目里,但真实的部署场景中,这些接口通常属于不同的模块或服务。
1. /license/generate:生成 License 的接口(服务端使用)
这个是授权系统的核心接口,用于生成一份绑定客户信息和功能权限的 License 文件。
2. /license/verify:验证 License 的接口(开发自测用)
这个接口不是必须上线的,它的主要作用是方便我们自己验证 License 文件是否正确,帮助开发人员在调试阶段快速判断授权内容是否合法。
.lic 文件并完成验证;3. /machine/info:采集目标服务器的硬件指纹
为了实现“机器绑定”,我们在生成 License 文件时,通常会要求客户提供部署机器的一些唯一硬件信息,比如:
cpuSerial)mainBoardSerial)macAddress)这些字段构成了机器的“指纹”,只有这些信息全部匹配,License 文件才会验证通过。
那客户怎么获取这些硬件信息?
我们对外提供了一个接口 /machine/info,客户可以在目标服务器上访问这个接口,获取一段 JSON 格式的机器信息。例如:
{
  "cpuSerial": "CPU123456",
  "macAddress": "00-14-22-01-23-45",
  "mainBoardSerial": "MB987654321"
}
拿到这份信息后,后台就可以用它来生成绑定了机器的 License 文件。
那有些客户无法访问接口怎么办呢?
实际中确实会存在一些客户的网络环境是内网隔离的,根本访问不了我们部署的采集接口服务,这种情况很常见,也很合理。
我们的解决方案是这样的:
方案一:客户本地运行采集工具
我们提供一个轻量的采集工具,比如 collector.jar;
客户在部署服务器上运行:
java -jar collector.jar
程序会自动读取硬件信息,并以 JSON 的形式输出到控制台;
客户将这段 JSON 复制发送给我们,我们后台就可以用它生成绑定 License。
这种方式最稳妥、最兼容,不依赖任何网络环境,也不涉及服务部署,只需要客户本地能运行 Java 就行。
方案二:客户手动提供硬件信息
如果客户实在没法运行 JAR 工具,我们也可以提供一个表单或文档模板,让客户手动填写这些字段(可能需要远程协助)。
简单来说:
最终目标只有一个:拿到足够完整、准确的机器指纹数据,用于后续生成绑定的 License 文件。
配置文件需要做哪些配置?
在我们的授权服务中,难免会涉及一些关键信息,比如私钥路径、Redis 地址、License 文件位置等等。为了便于统一管理和后期维护,我们建议把这些配置集中写在 application.yml 中。
这里我们只展示示例代码中需要用到的部分配置:
server:
  port: 8081
spring:
  redis:
    host: 127.0.0.1       # Redis 地址
    port: 6379            # 默认端口
    password:             # Redis 密码(推荐加密)
    database: 0           # 使用的 Redis 库编号
license:
  private-key:
    keystore-path: /Users/kaka/license_keys/privateKeys.keystore   # 私钥库路径
    alias: privateKey
    store-pass: pubwd123456         # keystore 的访问密码
    key-pass: priwd123456           # 私钥对应的密码
  public-key:
    cer-path: /Users/kaka/license_keys/certfile.cer                # 公钥证书路径
  client:
    license-path: /Users/kaka/licenses/DOCX-TST-202509-001.lic     # 本地 License 文件路径
    public-key-path: /Users/kaka/license_keys/certfile.cer
    time-record-path: /Users/kaka/licenses/last-startup-time.dat   # 客户端启动时间记录文件
  time-secret: mySuperSecretKey     # 启动时间加密密钥
  output-path: /Users/kaka/licenses/  # License 文件的生成输出路径
上面这些配置,基本上就覆盖了我们在生成和验证 License 时用到的关键字段:
time-secret 是为了防止用户“时间回拨”作弊,做时间加密校验时使用的。实际业务中我们都需要注意什么?
虽然我们这里用的是明文配置,但在真实项目中,直接写密码和路径是非常不安全的做法,有几个方面要特别注意:
包含敏感信息,不能直接暴露
key-pass、store-pass、Redis 密码等;配置文件不要直接上传到代码仓库
.gitignore;那我们要怎么对这些敏感字段加密?
这里推荐使用 Jasypt 插件来做配置项加解密,Spring Boot 已经有成熟的集成方案了:
加密流程是这样的:
key-pass: ENC(kdsafljsdf0923jdf==)
-Djasypt.encryptor.password=your-decrypt-key
ENC(...) 解密成明文,代码里用起来和原来一模一样。小结一下
这一部分是整个授权系统的重头戏,也就是当客户发来授权申请时,我们服务端怎么去生成一个 .lic 文件。
License 文件里会包含客户信息、授权时间、绑定机器、功能开关等等,并且会用私钥对这些信息做一次签名,防止别人篡改。
1. LicenseRequest
这个类用来接收前端传过来的参数,比如项目 ID、客户名、开始时间、到期时间、功能开关、绑定的机器等等。前端发起授权申请的时候,就是填这些字段。
package org.example.licenseplatform.model;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * 客户端请求生成 License 时提交的参数模型(来自前端页面)
 * 用于服务端生成签名后的 License 文件
 */
@Data
public class LicenseRequest {
    /** 项目 ID(用于区分不同的项目或产品线) */
    @NotBlank(message = "项目 ID 不能为空")
    private String projectId;
    /** 客户名称(公司名称或实际使用人) */
    @NotBlank(message = "客户名称不能为空")
    private String customer;
    /** 授权起始时间(单位:毫秒时间戳) */
    @NotNull(message = "起始时间不能为空")
    private Long issueDate;
    /** 授权过期时间(单位:毫秒时间戳) */
    @NotNull(message = "到期时间不能为空")
    private Long expireDate;
    /** 功能模块配置,可为空 */
    private Map<String, Boolean> features;
    /** 授权绑定的机器列表(支持 standalone 或 cluster 模式) */
    @NotNull(message = "机器指纹信息不能为空")
    @Size(min = 1, message = "至少绑定一台机器")
    private List<MachineInfo> boundMachines;
    /** 授权模式:standalone / cluster */
    @NotBlank(message = "授权模式不能为空")
    private String mode;
}
2. LicenseContent
这是我们最终要生成的 License 文件的内容结构,也就是会写进 JSON 里的东西。里面有授权编号(licenseId)、客户信息、授权时间段、功能模块配置、部署模式(单机/集群)、签名字段等等。
package org.example.licenseplatform.model;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * License 授权文件内容模型(最终写入 .lic 文件)
 * 包含了客户信息、授权时间范围、功能开关、绑定机器列表、授权模式和签名字段
 */
@Data
public class LicenseContent {
    /** 授权编号,全局唯一,用于内部追踪 */
    private String licenseId;
    /** 项目 ID(多项目区分) */
    private String projectId;
    /** 客户名称或公司名,用于识别客户身份 */
    private String customer;
    /** 授权生效时间(毫秒时间戳) */
    private Long issueDate;
    /** 授权过期时间(毫秒时间戳) */
    private Long expireDate;
    /** 授权功能模块配置 */
    private Map<String, Boolean> features;
    /** 多台绑定机器信息,用于集群部署识别 */
    private List<MachineInfo> boundMachines;
    /** 授权模式(standalone / cluster),用于行为控制 */
    private String mode;
    /** 首次使用时间(毫秒时间戳),用于记录首次加载并防止复制横向扩散 */
    private Long firstUsedAt;
    /** 签名字段(私钥签名后的密文,防止篡改) */
    private String signature;
}
3. LicenseConfig
我们把 application.yml 里的配置项都封装到这个类里了,比如私钥路径、公钥路径、License 文件输出目录等等。后面用起来就很方便,不用到处 hardcode。
package org.example.licenseplatform.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
 * License 配置项读取类
 * 绑定 application.yml 中以 license 开头的配置项
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "license")
public class LicenseConfig {
    /**
     * 私钥配置(用于生成 license 签名)
     * 对应 application.yml 中 license.private-key
     */
    private PrivateKeyConfig privateKey;
    /**
     * 公钥配置(用于客户端校验 license)
     * 对应 application.yml 中 license.public-key
     */
    private PublicKeyConfig publicKey;
    /**
     * License 文件输出路径(.lic 文件生成目录)
     * 示例:/Users/kaka/licenses/
     */
    private String outputPath;
    /**
     * 时间回拨检测的 HMAC 加密密钥
     * 用于校验启动时间记录文件是否被篡改
     */
    private String timeSecret;
    /**
     * 客户端配置项(License 校验时使用)
     * 对应 application.yml 中 license.client
     */
    private ClientConfig client;
    /**
     * 内部类:私钥相关配置
     */
    @Data
    public static class PrivateKeyConfig {
        /** keystore 文件绝对路径(.jks 格式) */
        private String keystorePath;
        /** keystore 中的别名 */
        private String alias;
        /** keystore 密码 */
        private String storePass;
        /** 私钥条目的访问密码 */
        private String keyPass;
    }
    /**
     * 内部类:公钥相关配置
     */
    @Data
    public static class PublicKeyConfig {
        /** 公钥证书路径(.cer 格式) */
        private String cerPath;
    }
    /**
     * 内部类:客户端运行时加载 License 所需路径
     */
    @Data
    public static class ClientConfig {
        /** License 文件路径(.lic) */
        private String licensePath;
        /** 公钥证书路径(建议使用 ${license.public-key.cer-path} 引用) */
        private String publicKeyPath;
        /** 上次启动时间记录文件(用于时间回拨防护) */
        private String timeRecordPath;
    }
}
4. LicenseIdGenerator
生成唯一的 licenseId,比如像这样:DOCX-TST-202509-001。会根据项目、客户、年月生成,再通过 Redis 来自增序号,保证唯一性。
package org.example.licenseplatform.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
 * License ID 生成工具类
 * 格式示例:DOCX-TST-202509-001
 */
@Component
public class LicenseIdGenerator {
    private static final String REDIS_KEY_PREFIX = "license:id:";
    @Autowired
    private StringRedisTemplate redisTemplate;
    /**
     * 自动生成带序号的 licenseId
     *
     * @param projectId    项目标识(如 docx-platform)
     * @param customerName 客户公司名称(如 测试公司)
     * @return licenseId 如 DOCX-TST-202509-001
     */
    public String generate(String projectId, String customerName) {
        String projectCode = toShortCode(projectId);      // 如:DOCX
        String customerCode = toShortCode(customerName);  // 如:TST
        String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); // 如:202509
        // Redis key: license:id:DOCX:TST:202509
        String redisKey = String.format("%s%s:%s:%s",
                REDIS_KEY_PREFIX, projectCode, customerCode, datePart);
        // 自增序号(从 1 开始)
        Long seq = redisTemplate.opsForValue().increment(redisKey);
        // 序号格式化为 3 位数字(如 001)
        String seqPart = String.format("%03d", seq);
        // 拼接完整 License ID
        return String.join("-", projectCode, customerCode, datePart, seqPart);
    }
    /**
     * 将输入转换为大写简写(保留前缀 4 位)
     *
     * @param input 原始字符串
     * @return 大写简写(最多 4 位)
     */
    private String toShortCode(String input) {
        if (input == null) return "NULL";
        // 只保留字母数字,转为大写
        String clean = input.replaceAll("[^a-zA-Z0-9]", "").toUpperCase();
        return clean.length() <= 4 ? clean : clean.substring(0, 4);
    }
}
** 5. LicenseService#generateLicense**
这个类就是核心的 License 生成逻辑了。流程大概是:
LicenseContent;.lic 文件,写到指定目录下。package org.example.licenseplatform.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.licenseplatform.config.LicenseConfig;
import org.example.licenseplatform.model.LicenseContent;
import org.example.licenseplatform.model.LicenseRequest;
import org.example.licenseplatform.util.JsonUtils;
import org.example.licenseplatform.util.KeyStoreUtils;
import org.example.licenseplatform.util.LicenseIdGenerator;
import org.example.licenseplatform.util.SignatureUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.PrivateKey;
/**
 * License 服务类:用于根据前端请求生成签名后的 License 文件
 */
@Service
public class LicenseService {
    private final ObjectMapper objectMapper;
    private final LicenseConfig licenseConfig;
    @Autowired
    private LicenseIdGenerator licenseIdGenerator;
    @Autowired
    public LicenseService(LicenseConfig licenseConfig) {
        this.objectMapper = JsonUtils.getMapper(); // 使用统一的 JSON 工具配置
        this.licenseConfig = licenseConfig;
    }
    /**
     * 根据 LicenseRequest 请求生成签名后的 License 文件
     *
     * @param request 前端提交的 License 请求参数
     * @return 是否生成成功
     */
    public boolean generateLicense(LicenseRequest request) {
        try {
            // 1. 构建 License 内容(签名前)
            LicenseContent content = new LicenseContent();
            // 自动生成唯一的 License ID
            String licenseId = licenseIdGenerator.generate(
                    request.getProjectId(), request.getCustomer()
            );
            content.setLicenseId(licenseId);
            content.setProjectId(request.getProjectId());
            content.setCustomer(request.getCustomer());
            content.setIssueDate(request.getIssueDate());
            content.setExpireDate(request.getExpireDate());
            content.setFeatures(request.getFeatures());
            // 2. 设置绑定机器列表(支持集群部署)
            content.setBoundMachines(request.getBoundMachines());
            // 3. 设置部署模式:standalone / cluster
            content.setMode(request.getMode());
            // 4. 初始签名字段设为空(参与签名的数据中不能包含签名本身)
            content.setSignature(null);
            // 5. 将 License 内容转为 JSON 字符串(用于签名)
            String jsonToSign = objectMapper
                    .writerWithDefaultPrettyPrinter()
                    .writeValueAsString(content);
            // 6. 加载本地 JKS 私钥
            PrivateKey privateKey = KeyStoreUtils.loadPrivateKeyFromJKS(
                    licenseConfig.getPrivateKey().getKeystorePath(),
                    licenseConfig.getPrivateKey().getAlias(),
                    licenseConfig.getPrivateKey().getStorePass(),
                    licenseConfig.getPrivateKey().getKeyPass()
            );
            // 7. 使用私钥进行签名
            String signature = SignatureUtils.sign(jsonToSign, privateKey);
            content.setSignature(signature);
            // 8. 构造 License 文件输出路径
            String outputPath = licenseConfig.getOutputPath() + licenseId + ".lic";
            File outputFile = new File(outputPath);
            Files.createDirectories(Paths.get(outputFile.getParent())); // 确保目录存在
            // 9. 将最终带签名的 JSON 内容写入 .lic 文件
            String finalJson = objectMapper.writeValueAsString(content);
            Files.write(outputFile.toPath(), finalJson.getBytes(StandardCharsets.UTF_8));
            return true;
        } catch (Exception e) {
            e.printStackTrace(); // 实际使用中应替换为日志记录
            return false;
        }
    }
}
在 License 授权平台中,除了服务端需要提供 License 文件生成功能,我们还需要一套完整的校验逻辑,来确保客户端或服务本身能够正确识别和验证本地的 License 文件。我们先来看一段服务端提供的校验接口,然后对比客户端启动时加载 License 的流程。
一、服务端 License 校验接口
这段逻辑提供了一个用于服务端主动验证 License 的接口,比如用于后台界面点击 "校验授权" 按钮时调用。
核心类:LicenseVerifierService
package org.example.licenseplatform.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.example.licenseplatform.client.LicenseLoadException;
import org.example.licenseplatform.common.Result;
import org.example.licenseplatform.config.LicenseConfig;
import org.example.licenseplatform.model.LicenseContent;
import org.example.licenseplatform.model.MachineInfo;
import org.example.licenseplatform.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.PrivateKey;
import java.security.PublicKey;
@Slf4j
@Service
public class LicenseVerifierService {
    private final ObjectMapper objectMapper = JsonUtils.getMapper();
    @Autowired
    private LicenseConfig licenseConfig;
    public Result<?> verify(String licensePath, String publicKeyPath, String timeRecordPath) {
        try {
            log.info("校验 License 文件: {}", licensePath);
            // 1. 加载 License 文件并反序列化
            LicenseContent license = loadLicense(licensePath);
            // 2. 验签
            Result<?> signatureResult = verifySignature(license, publicKeyPath);
            if (!signatureResult.isSuccess()) return signatureResult;
            // 3. 校验生效时间 & 过期时间
            Result<?> timeResult = verifyTime(license);
            if (!timeResult.isSuccess()) return timeResult;
            // 4. 校验硬件指纹
            Result<?> machineResult = verifyMachineInfo(license);
            if (!machineResult.isSuccess()) return machineResult;
            // 5. 校验首次使用时间
            Result<?> firstUsedResult = verifyFirstUsedAt(license);
            if (!firstUsedResult.isSuccess()) return firstUsedResult;
            // 6. 检查系统时间是否回拨
            Result<?> rollbackResult = verifyClockRollback(timeRecordPath);
            if (!rollbackResult.isSuccess()) return rollbackResult;
            return Result.ok("License 校验通过");
        } catch (LicenseLoadException e) {
            log.error("License 加载异常", e);
            return Result.fail(5001, "License 加载失败: " + e.getMessage());
        } catch (Exception e) {
            log.error("License 校验异常", e);
            return Result.fail(5002, "License 校验失败: " + e.getMessage());
        }
    }
    // 读取并反序列化 License
    private LicenseContent loadLicense(String licensePath) throws Exception {
        byte[] bytes = Files.readAllBytes(Paths.get(licensePath));
        String json = new String(bytes, StandardCharsets.UTF_8);
        return objectMapper.readValue(json, LicenseContent.class);
    }
    // 验证 License 签名
    private Result<?> verifySignature(LicenseContent license, String publicKeyPath) throws Exception {
        String signature = license.getSignature();
        if (signature == null || signature.isEmpty()) {
            log.error("验证签名失败,签名字段为空");
            return Result.fail(4001, "签名字段为空,非法 License");
        }
        license.setSignature(null);
        String unsignedJson = objectMapper.writeValueAsString(license);
        PublicKey publicKey = KeyStoreUtils.loadPublicKeyFromCer(publicKeyPath);
        boolean valid = SignatureUtils.verify(unsignedJson, signature, publicKey);
        if (!valid) {
            log.error("验证签名失败,License 文件可能被篡改");
            return Result.fail(4002, "签名验证失败,License 文件可能被篡改");
        }
        return Result.ok("签名验证通过");
    }
    // 验证生效时间与过期时间
    private Result<?> verifyTime(LicenseContent license) {
        long nowMillis = System.currentTimeMillis();
        long issueMillis = license.getIssueDate();
        long expireMillis = license.getExpireDate();
        if (nowMillis < issueMillis) {
            log.error("License 尚未生效,生效时间: {}", issueMillis);
            return Result.fail(4003, "License 尚未生效");
        }
        if (nowMillis > expireMillis) {
            log.error("License 已过期,过期时间: {}", expireMillis);
            return Result.fail(4004, "License 已过期");
        }
        return Result.ok("时间验证通过");
    }
    private Result<?> verifyFirstUsedAt(LicenseContent license) {
        if (license.getFirstUsedAt() == null) {
            log.error("License 缺少首次使用时间字段");
            return Result.fail(4007, "License 缺少首次使用时间字段");
        }
        long nowMillis = System.currentTimeMillis();
        if (nowMillis < license.getFirstUsedAt()) {
            log.error("当前系统时间早于首次使用时间,可能存在时间回拨风险");
            return Result.fail(4008, "当前系统时间早于首次使用时间,可能存在时间回拨风险");
        }
        return Result.ok("首次使用时间验证通过");
    }
    // 校验当前机器是否在授权机器列表中
    private Result<?> verifyMachineInfo(LicenseContent license) {
        if (license.getBoundMachines() == null || license.getBoundMachines().isEmpty()) {
            log.error("License 中未配置绑定机器信息");
            return Result.fail(4005, "License 中未配置绑定机器信息");
        }
        // 获取当前机器的硬件指纹
        String currentMac = MachineInfoUtils.getFirstMacAddress();
        String currentCpu = MachineInfoUtils.getCPUSerial();
        String currentBoard = MachineInfoUtils.getMainBoardSerial();
        String mode = license.getMode();
        // 单机模式:只比对第一台机器
        if ("standalone".equalsIgnoreCase(mode)) {
            MachineInfo only = license.getBoundMachines().get(0);
            boolean match =
                    safeEquals(only.getMacAddress(), currentMac) &&
                            safeEquals(only.getCpuSerial(), currentCpu) &&
                            safeEquals(only.getMainBoardSerial(), currentBoard);
            if (!match) {
                log.error("当前机器与授权机器不一致,License 校验失败(standalone 模式)");
                return Result.fail(4005, "硬件指纹不一致,当前机器非授权机器(standalone 模式)");
            }
            return Result.ok("机器指纹验证通过(standalone 模式)");
        }
        // 默认模式:cluster,遍历任意一台匹配即可
        boolean match = license.getBoundMachines().stream().anyMatch(bound ->
                safeEquals(bound.getMacAddress(), currentMac) &&
                        safeEquals(bound.getCpuSerial(), currentCpu) &&
                        safeEquals(bound.getMainBoardSerial(), currentBoard)
        );
        if (!match) {
            log.error("当前机器不在授权列表中,License 校验失败(cluster 模式)");
            return Result.fail(4005, "硬件指纹不一致,当前机器非授权机器(cluster 模式)");
        }
        return Result.ok("机器指纹验证通过(cluster 模式)");
    }
    // 检测时间回拨并写入记录
    private Result<?> verifyClockRollback(String timeRecordPath) {
        try {
            long nowMillis = System.currentTimeMillis();
            Path recordPath = Paths.get(timeRecordPath);
            if (Files.exists(recordPath)) {
                String content = new String(Files.readAllBytes(recordPath), StandardCharsets.UTF_8).trim();
                String[] parts = content.split(":");
                if (parts.length != 2) {
                    log.error("时间记录格式非法,可能被篡改");
                    return Result.fail(4006, "时间记录格式非法,可能被篡改");
                }
                String timestamp = parts[0];
                String hmacSignature = parts[1];
                String timeSecret = licenseConfig.getTimeSecret(); // 从配置中读取
                if (!HmacUtils.verify(timestamp, hmacSignature, timeSecret)) {
                    return Result.fail(4006, "检测到时间记录被篡改");
                }
                long lastStart = Long.parseLong(timestamp);
                if (nowMillis < lastStart) {
                    log.error("检测到系统时间回拨,License 校验失败");
                    return Result.fail(4006, "检测到系统时间回拨,License 校验失败");
                }
            }
            // 写入新的记录
            String newRecord = nowMillis + ":" + HmacUtils.sign(String.valueOf(nowMillis), licenseConfig.getTimeSecret());
            Files.write(recordPath, newRecord.getBytes(StandardCharsets.UTF_8));
            return Result.ok("时间回拨检测通过");
        } catch (Exception e) {
            log.error("时间回拨校验失败", e);
            return Result.fail(5003, "时间回拨校验失败: " + e.getMessage());
        }
    }
    private boolean safeEquals(String a, String b) {
        return (a == null && b == null) || (a != null && a.equals(b));
    }
}
这段接口的重点逻辑基本涵盖了 License 校验的各个环节:
这个接口适合在服务端暴露 API 来做测试验证用,但生产环境中 License 校验更多是在客户端或者启动阶段自动完成的,下面我们来看客户端的校验方式。
二、客户端 License 自动校验流程
客户端在服务启动时,会主动加载 .lic 文件,执行完整校验。
主要类:LicenseVerifier
// 执行 License 校验,返回授权内容
LicenseContent license = verifier.verify();
// 将授权状态注入 LicenseContext,全局可用
LicenseContext.setVerified(license);
完整逻辑拆解如下:
1. LicenseVerifier
该类负责从配置中读取路径,加载 License 文件,并执行核心的校验流程:
package org.example.licenseplatform.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.licenseplatform.model.LicenseContent;
import org.example.licenseplatform.util.JsonUtils;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
/**
 * License 校验器:负责整体加载和校验流程
 */
public class LicenseVerifier {
    private final ObjectMapper objectMapper = JsonUtils.getMapper();
    private final ClientLicenseConfig config;
    public LicenseVerifier(ClientLicenseConfig config) {
        this.config = config;
    }
    /**
     * 加载并验证本地 License 文件
     *
     * @return 校验通过的 LicenseContent 内容(可注入 LicenseContext)
     */
    public LicenseContent verify() {
        try {
            // 1. 读取 License 文件内容
            File licenseFile = new File(config.getLicensePath());
            if (!licenseFile.exists()) {
                throw new LicenseLoadException("未找到 License 文件:" + config.getLicensePath());
            }
            String json = new String(
                    Files.readAllBytes(Paths.get(config.getLicensePath())),
                    StandardCharsets.UTF_8
            );
            // 2. 反序列化为 LicenseContent 对象
            LicenseContent license = objectMapper.readValue(json, LicenseContent.class);
            // 3. 加载公钥
            PublicKey publicKey = config.loadPublicKey();
            // 4. 执行完整校验流程(签名、时间、硬件、时间回拨)
            LicenseValidator.validateSignature(license, publicKey);
            LicenseValidator.validateDate(license);
            LicenseValidator.validateHardware(license);
            LicenseValidator.validateFirstUsedAt(license);
            LicenseValidator.validateTimeRollback(config.getTimeRecordPath(), config.getTimeSecret());
            // 5. 校验成功,返回 License 内容用于注入 LicenseContext
            return license;
        } catch (Exception e) {
            throw new LicenseLoadException("License 校验失败:" + e.getMessage(), e);
        }
    }
}
2. LicenseValidator
这部分是具体的验证逻辑拆分:
validateSignature():验证签名是否有效validateDate():检查当前时间是否在授权范围内validateHardware():机器指纹是否匹配validateFirstUsedAt():首次使用时间合法性validateTimeRollback():时间回拨检测(带 HMAC)package org.example.licenseplatform.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.licenseplatform.model.LicenseContent;
import org.example.licenseplatform.model.MachineInfo;
import org.example.licenseplatform.util.HmacUtils;
import org.example.licenseplatform.util.JsonUtils;
import org.example.licenseplatform.util.MachineInfoUtils;
import org.example.licenseplatform.util.SignatureUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.PublicKey;
public class LicenseValidator {
    private static final ObjectMapper objectMapper =  JsonUtils.getMapper();
    /**
     * 验证 License 签名是否合法
     * @param license 被校验的 LicenseContent
     * @param publicKey 公钥(由服务端生成)
     */
    public static void validateSignature(LicenseContent license, PublicKey publicKey) {
        try {
            // 清除签名字段,重新计算签名前的 JSON 字符串
            String signature = license.getSignature();
            license.setSignature(null);
            String rawJson = objectMapper.writeValueAsString(license);
            if (!SignatureUtils.verify(rawJson, signature, publicKey)) {
                throw new LicenseLoadException("签名验证失败,License 非法或被篡改");
            }
            license.setSignature(signature); // 验签后恢复原值
        } catch (Exception e) {
            throw new LicenseLoadException("签名验证出错", e);
        }
    }
    /**
     * 验证 License 的时间是否合法(已生效 + 未过期)
     * @param license LicenseContent 对象
     */
    public static void validateDate(LicenseContent license) {
        try {
            long now = System.currentTimeMillis();
            long issueTime = license.getIssueDate();
            long expireTime = license.getExpireDate();
            if (now < issueTime) {
                throw new LicenseLoadException("License 尚未生效");
            }
            if (now > expireTime) {
                throw new LicenseLoadException("License 已过期");
            }
        } catch (Exception e) {
            throw new LicenseLoadException("时间格式非法或校验异常:" + e.getMessage(), e);
        }
    }
    /**
     * 校验当前机器是否符合 License 授权的硬件指纹
     * 区分 standalone(单机) 与 cluster(集群) 模式
     * @param license LicenseContent 对象
     */
    public static void validateHardware(LicenseContent license) {
        if (license.getBoundMachines() == null || license.getBoundMachines().isEmpty()) {
            throw new LicenseLoadException("License 中未配置绑定机器信息");
        }
        MachineInfo current = MachineInfoUtils.getMachineInfo();
        String mode = license.getMode();
        if ("standalone".equalsIgnoreCase(mode)) {
            // 单机模式只比对第一台
            MachineInfo only = license.getBoundMachines().get(0);
            if (!safeEquals(only.getMacAddress(), current.getMacAddress()) ||
                    !safeEquals(only.getCpuSerial(), current.getCpuSerial()) ||
                    !safeEquals(only.getMainBoardSerial(), current.getMainBoardSerial())) {
                throw new LicenseLoadException("当前机器与授权机器不一致,License 校验失败(standalone 模式)");
            }
            return;
        }
        // cluster 模式:遍历任意一台机器
        boolean matched = license.getBoundMachines().stream().anyMatch(bound ->
                safeEquals(bound.getMacAddress(), current.getMacAddress()) &&
                        safeEquals(bound.getCpuSerial(), current.getCpuSerial()) &&
                        safeEquals(bound.getMainBoardSerial(), current.getMainBoardSerial())
        );
        if (!matched) {
            throw new LicenseLoadException("当前机器不在授权列表中,License 校验失败(cluster 模式)");
        }
    }
    /**
     * 校验首次使用时间合法性(不能小于签发时间)
     */
    public static void validateFirstUsedAt(LicenseContent license) {
        Long firstUsedAt = license.getFirstUsedAt();
        long issueTime = license.getIssueDate();
        if (firstUsedAt == null) {
            throw new LicenseLoadException("首次使用时间为空,License 文件可能不完整");
        }
        if (firstUsedAt < issueTime) {
            throw new LicenseLoadException("首次使用时间早于签发时间,License 文件非法或被修改");
        }
        long now = System.currentTimeMillis();
        if (now < firstUsedAt) {
            throw new LicenseLoadException("系统时间早于首次使用时间,可能存在时间回拨风险");
        }
    }
    /**
     * 检查系统是否存在时间回拨(比上次运行更早)
     * 采用 HMAC 加密记录方式防止被恶意伪造
     *
     * @param timeRecordPath 本地记录路径
     * @param timeSecret HMAC 使用的密钥
     */
    public static void validateTimeRollback(String timeRecordPath, String timeSecret) {
        try {
            long now = System.currentTimeMillis();
            Path recordPath = Paths.get(timeRecordPath);
            if (Files.exists(recordPath)) {
                String content = new String(Files.readAllBytes(recordPath), StandardCharsets.UTF_8).trim();
                String[] parts = content.split(":");
                if (parts.length != 2) {
                    throw new LicenseLoadException("时间记录格式非法,可能被篡改");
                }
                String timestamp = parts[0];
                String hmac = parts[1];
                if (!HmacUtils.verify(timestamp, hmac, timeSecret)) {
                    throw new LicenseLoadException("检测到时间记录被篡改");
                }
                long last = Long.parseLong(timestamp);
                if (now < last) {
                    throw new LicenseLoadException("检测到系统时间回拨,License 校验失败");
                }
            }
            // 写入最新时间戳
            String newRecord = now + ":" + HmacUtils.sign(String.valueOf(now), timeSecret);
            Files.write(recordPath, newRecord.getBytes(StandardCharsets.UTF_8));
        } catch (IOException e) {
            throw new LicenseLoadException("时间回拨检测失败(文件IO异常)", e);
        } catch (NumberFormatException e) {
            throw new LicenseLoadException("时间回拨检测失败(时间格式异常)", e);
        } catch (Exception e) {
            throw new LicenseLoadException("时间回拨检测失败", e);
        }
    }
    /**
     * 安全字符串比较,防止空指针
     */
    private static boolean safeEquals(String a, String b) {
        return (a == null && b == null) || (a != null && a.equals(b));
    }
}
3. LicenseContext
校验通过后,会将授权状态缓存到内存中。
LicenseContext.setVerified(license);
在程序的任何地方都可以通过 LicenseContext.isVerified() 判断是否授权成功,也可以通过 LicenseContext.isFeatureEnabled("xxx") 判断某功能是否被授权。
package org.example.licenseplatform.context;
import org.example.licenseplatform.model.LicenseContent;
import java.util.Map;
/**
 * LicenseContext 是 License 校验通过后全局缓存授权状态的上下文工具类。
 * 可用于在系统任意位置判断是否通过授权、当前授权内容、功能是否启用等信息。
 *
 * 注意:LicenseContext 一般由 LicenseVerifier 在校验通过后注入初始化。
 */
public class LicenseContext {
    /** 标识当前系统是否通过 License 校验(默认 false) */
    private static volatile boolean verified = false;
    /** 全局缓存的 License 内容(包括功能模块、客户信息等) */
    private static LicenseContent license;
    /**
     * 校验通过后,注入授权状态和授权内容
     *
     * @param content 校验后的 LicenseContent 内容
     */
    public static void setVerified(LicenseContent content) {
        verified = true;
        license = content;
    }
    /**
     * 获取当前是否通过 License 校验
     *
     * @return true 表示校验通过
     */
    public static boolean isVerified() {
        return verified;
    }
    /**
     * 获取当前缓存的 License 内容对象(包含授权编号、客户名、功能等)
     *
     * @return LicenseContent 对象
     */
    public static LicenseContent getLicense() {
        return license;
    }
    /**
     * 判断某功能模块是否启用
     *
     * @param featureKey 功能模块名(如 exportExcel)
     * @return true 表示已授权该功能
     */
    public static boolean isFeatureEnabled(String featureKey) {
        if (!verified || license == null || license.getFeatures() == null) {
            return false;
        }
        Boolean enabled = license.getFeatures().get(featureKey);
        return Boolean.TRUE.equals(enabled);
    }
    /**
     * 获取某个功能模块的所有配置(适用于功能扩展为复杂结构时)
     *
     * @param featureKey 功能模块名
     * @return Object 对象,可自行强转为 FeatureSetting 或 Map
     */
    public static Object getFeatureRaw(String featureKey) {
        if (!verified || license == null || license.getFeatures() == null) {
            return null;
        }
        return license.getFeatures().get(featureKey);
    }
    /**
     * 获取所有功能配置 Map(如 exportExcel -> true)
     *
     * @return Map<String, Boolean>
     */
    public static Map<String, Boolean> getAllFeatures() {
        if (!verified || license == null) return null;
        return license.getFeatures();
    }
    /**
     * 清空上下文(用于测试或重新加载 License)
     */
    public static void reset() {
        verified = false;
        license = null;
    }
}
三、客户端还可以怎么做?(增强安全性与可控性)
为了防止攻击者绕过 License 启动校验,客户端还可以做以下增强:
1. 拦截器统一校验请求
通过 Spring 的 WebMvcConfigurer 注入一个拦截器:
package org.example.licenseplatform.config;
import org.example.licenseplatform.interceptor.LicenseVerifyInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private LicenseVerifyInterceptor licenseVerifyInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(licenseVerifyInterceptor)
                .addPathPatterns("/**") //  拦截所有路径
                .excludePathPatterns(
                "/license/generate", // License 生成接口
                "/license/verify",  // License 验证接口
                 "/machine/info",   // 机器信息接口
                "/health",          // 健康检查接口
                "/actuator/**",     // Spring Actuator
                "/static/**",      // 静态资源
                "/favicon.ico",    // 网站图标
                "/error"         //    错误页面
                );
    }
}
核心校验逻辑在拦截器中完成:
package org.example.licenseplatform.interceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.example.licenseplatform.context.LicenseContext;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
 * License 校验拦截器:用于在每个 HTTP 请求前进行 License 校验
 * 防止攻击者绕过 LicenseBootChecker 启动校验
 */
@Slf4j
@Component
public class LicenseVerifyInterceptor implements HandlerInterceptor {
    /**
     * 请求前执行:拦截未授权请求
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 如果未通过授权校验,拒绝请求
        if (!LicenseContext.isVerified()) {
            log.warn("拒绝访问:未通过 License 授权,URI = {}", request.getRequestURI());
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType("application/json;charset=UTF-8");
            try {
                response.getWriter().write("{"code":403, "message":"未通过 License 授权,禁止访问"}");
                response.getWriter().flush();
            } catch (Exception e) {
                log.error("响应写入失败", e);
            }
            return false;
        }
        // 已授权,正常放行
        return true;
    }
}
2. 某些功能模块做权限判断
在具体的业务模块里,也可以使用 LicenseContext.isFeatureEnabled("exportExcel") 来做开关判断:
if (!LicenseContext.isFeatureEnabled("exportExcel")) {
return Result.fail("当前功能未授权,无法导出");
}
进一步的拦截优化
前面我们介绍了客户端如何接入授权校验,比如在服务启动时加载 License、注册全局状态,在每个请求前用拦截器判断有没有授权,还可以在功能模块里加一行代码判断某个功能有没有开通。
这些写法虽然直接,但其实也有一些不太方便的地方:
isFeatureEnabled("exportExcel"),一不小心就可能漏掉;所以我们可以做两点优化,把整套授权机制封装得更干净、好用、可复用。
1. 注解 + AOP:更优雅地控制权限
我们可以自定义一个注解,比如 @LicenseCheck("exportExcel"),然后通过 Spring AOP 在方法执行前自动进行权限判断。
这样用起来就很清爽:
@LicenseCheck("exportExcel")
@GetMapping("/export")
public void exportExcel() {
    // 如果授权通过才会执行这里
}
不需要每个地方都手动写 LicenseContext.isFeatureEnabled(...),也不会有遗漏的风险。
2. 把授权模块单独封装成 SDK
除了写法更优雅,我们还可以把整套授权相关的逻辑(比如 LicenseVerifier、LicenseValidator、LicenseContext、拦截器等)封装成一个独立的模块,比如单独建一个 license-sdk 项目,然后打包成 Jar,通过 pom.xml 引入到各个业务项目里。
这样做有什么好处?
由于篇幅限制 这里我就不再写具体的例子来哈
为了确保授权文件只能在指定机器上运行,我们需要采集目标服务器的硬件指纹信息,包括 CPU 序列号、主板序列号、网卡的 MAC 地址等。这些信息会作为绑定内容写入到 License 文件中,后续客户端校验时就能判断当前机器是否是被授权的那台。
我们提供了一个接口 /machine/info,用于在目标机器上调用并返回当前机器的硬件信息。调用方式可以是:
curl http://127.0.0.1:8081/machine/info
返回结构如下:
{
  "cpuSerial": "0x000306a9",
  "mainBoardSerial": "MB-123456789",
  "macAddress": "A1:B2:C3:D4:E5:F6"
}
核心逻辑代码说明
这个接口背后的实现,核心就是调用系统命令行来读取机器的硬件信息。以下是示例代码片段:
public class MachineInfoUtils {
    public static MachineInfo getMachineInfo() {
        MachineInfo info = new MachineInfo();
        info.setCpuSerial(getCPUSerial());
        info.setMacAddress(getFirstMacAddress());
        info.setMainBoardSerial(getMainBoardSerial());
        return info;
    }
    public static String getCPUSerial() {
        return CommandExecutor.exec("dmidecode -t processor | grep ID");
    }
    public static String getMainBoardSerial() {
        return CommandExecutor.exec("dmidecode -t baseboard | grep Serial");
    }
    public static String getFirstMacAddress() {
        // 获取第一个非回环网卡的 MAC 地址
        ...
    }
}
这里执行了几个系统命令(以 Linux/Mac 为例):
dmidecode -t processor | grep ID 获取 CPU IDdmidecode -t baseboard | grep Serial 获取主板序列号注意事项:
dmidecode 工具,服务器可能需要 root 权限才能执行;wmic 指令);其实我们可以把这个接口打包为一个单独的 jar 工具,交给客户在他们的机器上运行,收集到的信息可以通过后台系统上传或人工复制给我们,用于生成绑定的 License 文件。这样既保证安全,也方便授权流程闭环。是否要上传/记录到数据库,取决于我们后台系统是否需要支持续期、重发等能力。
为了验证我们整个授权流程是否跑通,这里展示三组接口的测试截图和简单说明。
/machine/info:采集目标服务器的硬件指纹这个接口的作用是从部署机器中采集唯一性信息,比如 CPU 序列号、主板序列号、MAC 地址等,用于后续和 License 文件做绑定与校验。
从测试截图可以看到,我们在本地 Mac 上调用 /machine/info 得到的结果如下:
由于 Mac 环境下 dmidecode 命令不可用,因此 CPU 和主板序列号为空,这属于正常情况。在 Linux 或 Windows 上运行时会有完整数据。
/license/generate:生成 License 的接口这个接口是用来根据我们填写的客户信息、授权功能、绑定的机器等信息,来生成一个 .lic 文件。
我们这里传的参数是这样的(只展示关键字段):
{
  "projectId": "DOCX",
  "customer": "TST",
  "issueDate": 1753977600000,
  "expireDate": 1759248000000,
  "mode": "cluster",
  "features": {
    "exportExcel": true,
    "logMonitor": true
  },
  "boundMachines": [
    {
      "cpuSerial": "CPU123456",
      "macAddress": "00-14-22-01-23-45",
      "mainBoardSerial": "MB987654321"
    }
  ],
  "contractId": "HT-202508-001",
  "receiptId": "FKSZ-202508-882"
}
也就是填清楚项目信息、功能开关、有效时间、授权机器这些。
请求发出后,会在本地生成一份 .lic 授权文件,实际的返回结果和生成效果如下:
这个就表示生成成功,路径下也确实有对应文件。后续我们就可以拿这个 License 文件去客户端验证使用了。
/license/verify:验证 License 的合法性这个接口用于服务端或开发调试阶段快速验证一个 License 文件是否有效,是否与当前服务器信息匹配,是否过期等。
测试中我们传入一个生成好的 .lic 文件:
GET /license/verify?licensePath=/Users/kaka/licenses/DOCX-TST-202509-001.lic
得到的结果如下图所示:
提示“硬件指纹不一致”,说明当前服务器的机器码与该 License 文件绑定的机器不匹配。这种错误在真实部署中可能是:
这篇文章写得已经挺长了,很多代码为了篇幅就没一一展示,比如完整的 License 验证流程、异常处理逻辑、拦截器的初始化方式等等。
如果有需要看一个相对完整点、可以跑起来的例子,我这边把上面写的伪代码逻辑放在 GitHub 上了,地址如下:
github地址
需要说明的是,这只是一个用来学习和参考的 Demo,主要目的是帮助大家搞清楚授权机制该怎么设计,不建议直接拿去上生产。如果真要用于实际项目,还需要做不少安全加固,比如加强加密、防篡改处理、异常兜底逻辑等。
可以先跑一跑,再根据自己的需求做调整,有兴趣可以看一下。
说到底,其实市面上已经有很多成熟的 License 授权方案了,像 TrueLicense、JLicense、一些收费的 SDK 等等。如果只是简单做个授权,直接拿来用也完全没问题。
那为啥我还要自己搞一套呢?主要还是为了能更灵活地适配自己的业务场景。
一方面,有些客户的部署环境比较特殊,比如是私有化部署,或者是在局域网里运行的系统,那些需要联网授权的方案基本用不了,必须要能本地校验,甚至得适配集群、多台机器这种情况。
再一个,我们这边的授权内容也不只是控制“能不能用”,还要细化到某个功能模块开不开、授权了哪些配置、有没有绑定合同号之类的,这些在现成工具里通常不支持,或者集成起来很麻烦。
还有就是安全性。有些工具对时间回拨、反篡改这些场景支持得不太够,但我们这边对安全是有要求的,像时间回拨校验、首次使用时间校验、本地记录文件的 HMAC 签名等等,这些都得我们自己做才放心。
最后就是维护成本的问题。我们这套机制做成了 SDK,各个系统都能直接接入,而且代码是我们自己写的,后续要改、要加字段、要对接平台都很方便,也不会出现外部库升级带来的兼容性问题。
所以总结一下,并不是现成方案不好用,而是我们想要的刚好不是“通用场景”,而是那种“偏业务”、“偏安全”、“偏私有化”的定制化能力。与其勉强适配别人的,不如自己写一个更贴合需求的,前期花点时间,后续接入和维护都更舒服。
 
                    