适配私有化部署,我手写了套支持离线验证的 License 授权系统

离线部署 + 不可控环境,授权成了难题

我之前在一家公司做过一段时间自研产品的研发,做的项目类型比较多,既有内部系统,也有对外的产品,To C、To B 都接触过。

我们公司当时主推的产品大致分为两类:

  • 一种是 纯 SaaS 模式的 To C 产品,服务部署在我们自己的服务器上,客户通过开账号、开权限的方式使用,收费方式也很常规:比如试用期 + 续费订阅,控制逻辑全在我们后台。
  • 另一种是 纯私有化部署的 To B 产品,一般面向的是政企类客户。

政企客户有个非常典型的需求 ------ 项目必须做完全私有化部署,运行在他们内网服务器上,且不能联网

这就导致我们的产品一旦部署出去,整个运行环境都是离线的、我们完全不可控的。 那时候我们经常遇到一个很现实的问题:

客户说:"我们先试用一下你们的产品,一个月后觉得 OK 再走采购流程。"

听起来没啥毛病,但问题是:

产品要"试用",就得部署到他们自己的服务器上。我们没有权限远程登录、也无法像 SaaS 模式那样通过账号系统来控制权限和使用时间。

在 C 端 SaaS 产品里,一切都是"云端可控"的:

用户是否开通、功能是否可用、试用是否到期,全都可以在后台配置、统一管理。

实在不行,我们可以直接把账号禁用、服务关停,客户根本没办法继续用。

但私有化部署就完全不同了:

产品安装包发出去,装在哪台服务器、运行多久、是否备份快照,我们都无法得知。一旦部署出去了,客户想继续用,不和我们联系也完全可以......

而从技术角度来看,如果我们不加任何控制,客户可以轻松地复制整个目录、还原镜像、无限期运行,这就等于是把我们的产品"白送"给了对方

所以,为了避免这种情况,我们必须想办法控制"试用期"和"授权范围",但又不能依赖网络,这时候就需要一套能在离线环境运行的授权机制,让我们的产品即使在"断网"的政企环境里,也能自动识别出:

  • 当前授权是否有效?
  • 是否过期了?
  • 是否部署在授权设备上?
  • 是否开放了对应功能模块?

我们后来采用了业内较为成熟且通用的解决方案 ------ License 许可证授权机制,来实现对试用期、功能权限和设备环境的离线控制。


那License 到底能干嘛?它不只是简单的一张"许可证"

在政企客户这种私有化部署场景下,我们为什么会选择用 License 这种方式来做授权?除了防止白嫖,它其实还解决了很多实际的问题。

最基本的当然是 控制软件能不能用,比如只允许客户使用 30 天,时间到了自动失效;但这只是最基础的能力。一个完善的 License 授权系统,应该具备以下这些作用:

首先,它可以帮我们限制产品的使用范围。比如说这个 License 是给某个企业内部用的,那它就不能被拿去对外售卖;又比如 License 中可以写明这个软件是给单用户使用的,不能装在 10 台服务器上无限跑。

其次是控制功能开放的等级。有的客户只买了基础版,那我们只开放核心模块;买了高级版,才开放一些进阶功能,比如导出、接口调用、大数据分析等等。License 文件里会写明具体开了哪些功能,程序运行时就能据此判断给不给用。

第三点,当然就是控制使用时间。这也是我们场景中最常用的:先给客户一个 30 天试用版,时间到了,如果没续约就自动停用。授权时间既可以是固定日期,也可以按激活后多少天计算。

还有一个比较隐性的作用是知识产权声明。License 文件其实相当于告诉客户:这套软件是我们的资产,你有使用权,但不能乱改、乱发,尤其不能反编译、拿去商用二次分发。

除此之外,有些 License 还会约定一些"额外服务条款",比如这个授权是否包含免费技术支持、是否能享受后续更新、是否满足某些行业的合规标准(比如涉密、审计、国产化要求等)。

说到底,License 就像是产品和客户之间的一份"合同",不同的是它既能写在纸上(采购协议),也能写在代码里(授权逻辑)。对于我们来说,它最大的价值就是:

  • 能管得住时间
  • 控得住功能
  • 限得住环境
  • 还防得了复制、破解和绕过

这也是为什么在私有化部署的场景下,我们最终选择用 License 来做离线授权的根本原因。


那我们要做自己的 License 系统,它至少得做到这些事儿

讲了那么多 License 的好处,那问题来了:
我们要做自己的 License 授权系统,怎么保证它足够安全、可控?

市面上确实有很多现成的 License 组件,但每个业务场景不一样,我们做的是离线的、私有化部署、政企客户使用,自己掌握可控性才最关键。

结合我们实际踩过的坑,下面这些功能几乎是"标配",缺一不可。

使用时间限制:防止客户无限期白嫖

最常见的就是客户说"我们试用一个月",但是你不加时间限制,部署完了他想用多久就用多久。

我们可以在 License 中记录"授权起止时间",程序启动时自动验证当前时间是不是在范围内。

比如:2025年9月1日 ~ 2025年9月30日,到期后软件直接拒绝运行。

而不是靠人工催客户:"哥,月底记得付钱哦"。

控制功能模块:不是所有人都能用全功能

有些客户只买了基础版,功能就得收着点;买了专业版的,我们才开放更多能力,比如导出报表、第三方接口对接等。

License 文件里可以写明"哪些模块可以用",比如:

  • 基础版:只能查看和导入
  • 专业版:支持导出、分析、API 访问
  • 企业版:全功能 + 协议对接 + 定制扩展

程序里按模块检查 License 信息决定是否开放。

绑定机器:防止 License 拿去复制复用

如果 License 文件不绑定设备,客户部署一套后可以无限复制,拿去别的服务器继续用。我们可以通过绑定机器信息(MAC 地址、CPU ID、主板序列号等)来控制授权。

比如:这个 License 只能部署在机器 A 上,换台机器运行就会校验失败。

防止文件被篡改:不能直接改 JSON 绕限制

如果 License 是明文 JSON,那客户可能直接改字段:

本来授权到 9 月 1 日,他一改变成 2099 年 1 月 1 日,就无限使用了。

所以我们必须加上数字签名(比如用私钥签名,公钥验证),只要内容被改动,签名校验就会失败,系统就直接报错拒绝启动。

防止反编译绕过校验逻辑

Java 项目很容易被反编译,看源码后改一句逻辑:

java 复制代码
// if (!isLicenseValid()) return false;
return true;

这不就直接绕过验证了吗?我们做了两件事来防止这个问题:

  1. 代码混淆:使用 ProGuard 或 Allatori 等混淆工具,把方法名、变量名混乱化,让人看不懂。
  2. 代码加密:比如配合 XJar 对 jar 包进行加密,防止反编译还原源码。

防止系统时间回拨:不能靠调系统时间绕开试用限制

有些人会动歪脑筋:发现到期后不能用了?

那我直接把系统时间调回一个月前,是不是就能继续用了?

这时候我们需要"时间回拨检测"机制,比如:

  • 在本地记录上次运行时间
  • 当前系统时间必须大于等于上次记录时间
  • 如果发现系统时间回退,直接报错退出

可找回、可续期:别把授权做成"一次性买卖"

万一客户把 License 文件删了、备份没做好、需要更换服务器,那是不是就完全无法用了?

我们需要设计一个安全的补发流程,比如:

  • License 内含唯一授权编号
  • 可通过编号 + 签名 + 企业身份申请补发
  • 同时支持后续授权续期、升级功能版本

这样客户体验也不会太差,销售流程也更可控。

这些功能听起来是不是觉得又多又麻烦,好像要做一大堆额外的事情?

其实不然------它们都是我们在实际交付过程中,一点点踩坑踩出来的经验总结。

我们可以把它理解为:我们不是在"给系统加功能",而是在"把那些容易被钻空子的漏洞一个个堵上"。

否则,一套系统部署过去,客户动一动时间、反个编译、复制个 License 文件......

我们辛辛苦苦做的产品就变成"白送的"。

所以说到这儿,有些同学可能也好奇:
这些我们担心的问题,客户真的会动手去搞吗?他们是怎么搞的?我们又是怎么防住的?


破解攻击手段盘点 & 我们是怎么一一防住的?

如果你也做过私有化交付,那你肯定知道一件事:
客户从来不会直接告诉你"我们想白嫖" ,但他们的一些操作,总让人觉得哪里不太对劲 但是又很无语。

比如下面这些情况,你可能听说过,也可能亲眼见过------我们统统都踩过坑,也都一一做了应对。

第一类:直接修改 License 文件

有些系统的 License 文件是明文格式,比如一个 JSON 文件。里面可能写着这样的字段:

json 复制代码
"expireDate": "2025-08-31"

客户拿到文件后,发现这个字段管控了过期时间,直接打开编辑器改成:

json 复制代码
"expireDate": "2099-12-31"

保存、重启程序------居然真能用,等于绕过了付费续期。

这种情况我们是怎么防的?

我们当然不会把 License 文件做成"信任用户"的形式,而是采用非对称加密签名机制

  • 签发时用私钥签名
  • 程序里只保留公钥进行验证
    只要客户改动了文件的哪怕一个字节,程序就会直接拒绝加载,提示"License 校验失败"。

第二类:把系统时间往回调

有的客户也不去动 License,而是动了更"隐蔽"的地方------服务器时间

比如试用期是一个月,到期之后他们把服务器时间调回一个月前,再次运行程序。结果发现还能继续用,相当于"时间倒流白嫖"。

这种行为,在私有化部署中其实非常常见,程序一旦跑在客户自己的服务器上,时间由他们掌控,如果没有额外校验,很容易被绕过。

那我们要怎么防这种行为呢?

我们可以设计一套"时间回拨检测"机制,核心逻辑就是:

每次启动程序时,检查当前系统时间是否比"上一次成功启动时间"更早。如果是,就判定为时间回拨,拒绝启动。

那具体要怎么实现呢?

程序运行时会读取一个本地的时间记录文件,比如叫 time-record.json。这个文件格式其实非常简单:

makefile 复制代码
1693267200000:aa94dd0f3cbf3f7f3f5da3e3cba...

前半部分是时间戳(表示上次成功启动的时间),后半部分是签名(用密钥通过 HMAC 算出来的)。

我们每次启动程序时,做三件事:

  1. 获取当前系统时间
  2. 读取 time-record.json 中记录的"上次启动时间"
  3. 比较两者大小 ------ 如果当前时间比上次早,就报错并终止程序

这样一来,系统时间被回调就能被精准识别出来。

那如果客户直接删掉或者改这个记录文件呢?

我们也考虑了这种场景。

程序在处理 time-record.json 的时候,区分两种情况:

第一次启动(也就是文件不存在)

程序会从 License 文件中读取一个 firstUsedAt 字段,这个字段是在授权平台签发 License 时写入的,表示程序可信的第一次使用时间

当发现记录文件不存在,程序就会判断:

  • 如果当前时间和 firstUsedAt 接近(相差不超过 10 秒),说明是真正的首次启动,程序会自动生成记录文件

  • 如果差距很大,说明文件被删除了,程序会直接终止启动,并提示:

    复制代码
    时间记录文件缺失,疑似被篡改或删除

所以,客户删掉记录文件,是无法绕过校验的。

唯一能通过的,是首次安装部署 + 与 License 中的 firstUsedAt 时间相匹配的情形。

那这个签名有什么用呢?

为了防止客户手动改这个文件,比如把时间随便调成一个旧值,我们在保存时附带了一个签名。

签名是通过 HMAC 算法加密时间戳得来的,密钥是写在代码或配置文件里的。

程序每次启动时会:

  • 重新计算一次签名
  • 和记录文件中的签名对比
  • 对不上就报错终止,提示"时间记录被篡改"

我们最终要达到的效果

  • 系统时间被回调 → 被识别出来,程序拒绝启动
  • 记录文件被删除 → 不是首次部署的情况下,程序拒绝启动
  • 记录文件被修改 → 签名校验失败,程序拒绝启动

这些检测手段叠加在一起,还是能有效杜绝"通过时间回拨延长使用期限"的行为。

第三类:License 跨机器复用

这种行为最常见了。

比如客户拿着我们授权的一份 License,部署在了他们主服务器上;过了几天,他们又偷偷把 License 文件复制到其他服务器上,又部署了一套。

他们以为只要是合法文件就能随便用,但实际上我们根本不会"只看文件"。

那这种我们是怎么防的?

我们在生成 License 文件时,会把当前机器的硬件指纹信息绑定进去,比如:

  • CPU 序列号
  • 主板 UUID
  • MAC 地址

License 校验时会比对当前机器的硬件信息,一旦不匹配,就直接报错,拒绝加载。

这种"硬件绑定"机制相当于给 License 上了锁,即使复制文件过去,也不能直接复用。

第四类:反编译绕过校验逻辑

Java 项目的另一个"硬伤"就是太容易被反编译了。

比如我们代码里写的:

java 复制代码
if (!isLicenseValid()) return false;

客户只要用 JD-GUI、Jadx 之类的工具反编译一下,把这段逻辑手动改成:

java 复制代码
return true;

重新编译一下,License 验证就形同虚设了。

那这种我们要怎么防呢?

首先,我们可以使用 ProGuard 或类似工具对代码做了混淆:类名、方法名都变成了 a、b、c,看都看不懂。

其次,我们还可以通过 XJar 加壳加密,把整个代码逻辑加密成 JVM 才能识别的格式,普通反编译工具完全打不开。

这就大大提高了反编译破解的门槛,让"绕过验证"变得几乎不可能。

第五类:伪造"找回 License"的场景

有些客户会声称"License 文件不小心删了,能不能补发一份",但实际可能是想额外搞一份 License,部署到其他机器上偷偷用。

我们是怎么防止这种"伪装找回"行为的?

每份 License 都带有唯一的授权编号(License ID),同时绑定了企业标识(如项目 ID、客户名称、税号、唯一 key 等)和机器指纹信息(CPU、MAC、主板等),并在签发时记录在案。

如果客户提出补发请求,我们系统可以根据 License ID 找到完整的授权记录,包括签发时间、绑定机器等内容,支持原样补发

我们不会重新生成新的 License,而是将原始信息还原,确保补发的 License 与最初签发的完全一致。

一旦发现客户尝试伪造信息、重复申请 License,系统也能识别出来,并拒绝补发。

总的来说:

上面这些手段听起来可能有些"离谱",但我们确实一个个都见过。

有时候客户不会告诉你他动了手脚,他只会说:"你们系统怎么又出错了?"你要是不提前做防护,就等于默认被白嫖。

所以我们做 License,不是为了"走个授权流程",而是要真正让授权落到实处、落到细节

做到哪怕被部署到了客户机房里,也必须在我们授权范围内使用,超出就立刻失效------这才算是把"授权机制"做明白了。


功能权限怎么控制?不同客户怎么用不同功能?

讲完了怎么防破解、防篡改,咱们再说一个在实际交付中非常常见、但又容易被忽略的问题------功能控制

很多人一听 License,第一反应就是:不就是控制"能不能用"吗?

其实没那么简单,真正的核心用途之一,是控制"能用多少""能用到什么程度"

我们在做私有化系统时就踩过这个坑。产品功能做得很全,总共十来个模块,但客户买的版本不同,使用权限也不同:

  • 有的客户买的是入门级,只需要简单的查看、导入导出功能;
  • 有的客户要的是商业版,需要额外的分析和审计模块;
  • 还有一些政企客户,直接上旗舰版,全功能解锁。

如果不做功能授权,那客户无论买哪个版本,打开都是全功能版本,这不就"买椟还珠"了吗?

类似下面这种版本功能对比,就是我们日常交付中经常要面对的问题:

所以说,License 不能只是"开门钥匙",它还得是"权限清单" ,告诉程序我们到底有哪些功能能用,哪些不能动。

那我们是怎么做的?

在给客户生成 License 文件时,我们会写进去一个"功能权限列表"。不同版本写法不一样,比如:

  • 基础版:只能用导入、导出、查看
  • 商业版:再加上分析、审计
  • 旗舰版:全功能解锁,连用户管理、插件系统都能用

程序启动时会读取 License 文件,把这些权限加载成一个"当前授权功能列表"。接下来所有的功能调用、菜单渲染、接口请求,都会基于这份授权来判断。

比如:

  • 某个导出接口:会先判断 LicenseContext.hasFeature("exportExcel")
  • 高级分析页面:前端会判断有没有 advancedAnalytics 的权限,没有就直接隐藏按钮

这样,客户看到的页面,和能点的功能,就会严格符合他们买的版本。该看见的能看见,没买的功能连入口都没有。

那我们的后台是怎么配置这些功能模块的?

在实际业务中,不同项目的授权需求差别很大:

  • 有的项目根本不需要分功能点;
  • 有的项目则分得很细,什么"基础版"、"高级版"、"旗舰版"都有;
  • 同一个版本下能用的功能点也不一样,比如 A 项目支持"多用户",B 项目根本没有这个功能。

如果每次都靠手动勾选功能来生成 License,那工作量大、容易出错,也不利于后期维护。

所以我们专门设计了一个"功能模块包配置中心",用来集中管理不同项目、不同版本对应的功能点。

配置功能模块包之前,代码层面要先准备好

前提是:功能点本身要先在后端代码里实现并接入授权判断逻辑。比如:

java 复制代码
if (LicenseContext.hasFeature("exportExcel")) {
    // 执行导出逻辑
}

也就是说,功能点 key 是程序已经支持的开关项,配置中心只是控制开或关。后台不能随便写一个 key,前端和后端都识别不了。

核心理念:

  • 每个项目可以配置是否启用模块包机制;
  • 启用了之后,可以定义多个模块包(比如"基础版"、"高级版"、"旗舰版"等);
  • 每个模块包下可以勾选对应的功能点;
  • 功能点是标准化的 key,比如 exportExcellogMonitorcrmIntegration 等;
  • 最终生成 License 文件时,后端会根据所选项目和模块包自动写入对应功能列表。

举个栗子:

比如某个"文档平台"项目,我们给它配置了两个模块包:

  • 基础版:只勾选了"导出 Excel"、"数据导入"、"API 接口"
  • 高级版:在基础的功能上,新增了"多用户支持"、"审计日志"、"CRM 对接"等

生成 License 的时候,如果选择的是"文档平台" + "高级版",那后端会自动填充这些功能点,不需要前端再手动传哪些功能勾选了。

这样有几个明显的好处:

  • 不同项目之间可以按需定制模块包;
  • 功能点是统一维护的,防止拼错 key;
  • 后续维护和升级更方便,不需要修改前端逻辑;
  • 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 文件了。这个过程是在后台操作的,有一个专门的页面来填写相关信息并下载授权文件。

页面大概长这样(如下图):

整个页面其实就是一个表单,发证人需要按要求填写几个关键字段:

  • 选择项目 :比如"文档智能平台"、"AI 中台"等。每个项目都有一个固定的项目标识,比如文档平台就是 DOCX,后端生成 License 文件时会用这个标识拼接文件名和内容。
  • 客户名称 :填写这次授权的客户名称(比如 TST、ABC 公司等),同时系统也会为每个客户分配一个客户标识(如 TST),用来做 License 文件命名和归属识别。
  • 模块包 :如果该项目开启了"功能模块包"配置,那这里就会展示可选的模块包(比如"基础模块包"、"高级模块包")。选择其中一个,系统就会自动填充对应功能点。如果该项目没有启用模块包机制,那这栏就不会出现,表示该项目是全功能授权或不分功能权限。
  • 部署模式:填写系统部署方式,比如"单机部署"或"集群部署",后续客户端校验也会参考这个值。
  • 签发日期 / 过期日期:授权的有效期范围。
  • 绑定服务器信息:可以绑定一台或多台服务器,用于限制 License 只能在特定机器上使用。每台服务器需要填写 CPU 序列号、MAC 地址、主板序列号。

填写完这些信息后,点击"生成 License 文件",系统就会根据选择的项目、客户、模块包和有效期,生成一个签名后的 .lic 授权文件,供客户下载使用。

后台接收到的请求参数大概是这样的:

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
customer 客户标识,表示授权给谁,比如 TST
issueDate / expireDate 授权的起止时间,单位是毫秒时间戳
mode 部署模式,比如 standalone(单机)或 cluster(集群)
features 功能权限列表,如果选择了模块包,会自动填充对应功能点;如果未启用模块包,则可能是手动填写或为空
boundMachines 要绑定的服务器列表,每台服务器填三个硬件标识,用于做机器绑定校验

生成成功后,License 文件会以如下格式命名:

复制代码
DOCX-TST-202509-001.lic

命名规则中包含了项目、客户、时间等信息,方便后期归档和追踪。

这些生成记录我们也会保存到数据库中

为了方便后续的补发、续期、查询、审计等操作,我们在生成 License 文件时,也会把这次的授权信息一并保存到数据库中。

保存的内容和生成时填写的请求参数基本一致,比如:

  • 授权的项目 ID 和项目名称;
  • 被授权客户的名称(标识);
  • 授权有效期(起止时间);
  • 使用的模块包(如果有);
  • 功能点列表(features 字段);
  • 绑定的服务器信息(硬件指纹);
  • 生成的 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 列表"页面,方便我们随时查看历史授权记录,并支持后续的 续期补发**操作。

这个页面主要用于展示和筛选历史发放过的 License 文件,字段包括:

字段 说明
License ID 授权文件名称,一般由项目标识 + 客户标识 + 日期组成,例如 DOCX-HZSY-202509-001.lic
客户名称 被授权的客户公司名
所属项目 授权项目的名称,如"文档平台"、"AI 文档助手"等
模块包 授权时所选的模块包名称(如果该项目启用了模块机制)
签发日期 License 的生成时间
过期日期 License 的有效期结束时间
状态 当前是否过期,分为"正常"或"已过期"
操作 可执行"续期"或"补发"操作

页面功能说明:

  • 筛选功能:支持按客户名称 / License ID、所属项目、模块包、状态进行筛选,方便快速定位;

  • 状态判断:系统根据 License 的过期时间自动判断状态,显示"正常"或"已过期";

  • 续期操作

    • 如果客户续签合同或升级模块,可以点击"续期"重新授权;
    • 后台将沿用原始授权信息,生成一个新的 License 文件;
  • 补发操作

    • 客户丢失了文件、部署失败等场景,可以通过"补发"下载原文件;
    • 内容保持一致,不影响原有授权。

支持自动过期提醒和客户通知

为了提高授权运维效率,我们还接入了一些自动化能力:

  • Redis 缓存:将所有即将过期的 License 做每日缓存,方便定时任务快速扫描;

  • 定时任务(任务调度) :每天凌晨定时扫描所有即将过期(如 15 天内)或已过期的授权;

  • 消息队列:将扫描出的"待提醒客户"消息推入 MQ,由异步线程处理通知流程;

  • 钉钉 / 飞书通知

    • 可以给内部运营群发送"客户授权即将过期提醒";
    • 如果配置了客户的 webhook,我们还可以给客户发送自动提醒;
  • 续期链接自动生成

    • 提醒中可附带"续期链接",点击后可快速跳转到后台,选择新的过期时间一键续期。

这套能力的核心目的就是:
让授权管理更自动、更及时,避免客户因 License 过期导致产品不可用,提升服务体验和授权可控性。

那实际操作上要注意啥?

我们在落地过程中也踩过一些坑,这里整理几个建议供参考:

第一,功能名不要写死在代码里

一开始我们直接用字符串判断,比如 if (feature == "exportExcel"),刚开始还行,但随着项目多了、功能变了,就容易出问题。建议统一搞个功能标识枚举类,或者放在配置文件里集中管理,这样后期修改和维护都更方便,避免到处硬编码。

第二,前后端逻辑必须保持一致

功能限制不是后端一个人的事。后端负责做接口层的权限判断,前端也要根据权限控制按钮和菜单的可见性。推荐做法是:后端校验通过后,把授权功能点列表返回给前端,让它根据这些 key 控制界面展示。不要出现"后端说没权限,前端还能点进去"的尴尬情况。

第三,授权升级要留好口子

有些客户一开始用的是"基础版",用着用着想升级"高级版"或"旗舰版"。这时候我们需要能快速重新签发一个新的 License 文件,自动覆盖原有授权。同时,License 校验逻辑最好支持热更新,不依赖重启,能自动感知授权变更,体验才会好。

功能控制这件事,看上去不复杂,但真正落到业务上,其实是整个授权体系中非常关键的一环。

因为大多数客户不是"用不了",而是"想多用一点",想绕一绕、试试看 。功能模块的精细控制,不只是防止被白嫖,更是我们产品分层销售、差异化定价的核心手段。可以说,功能模块是我们商业护城河的一部分,也是 License 授权机制最直接的变现出口。


License 实践:从生成到校验的完整流程

前面我们已经讲清楚了什么是 License、它的作用、为什么要做授权控制,那这时候可能有同学会问------

说了这么多,那到底要怎么做呢?我要怎么生成 License,又如何让程序去校验它?

我们这里来简单实现一个:从密钥生成、License 生成、程序校验全流程跑通。这里我会用 Spring Boot 项目来组织代码结构,结合离线场景考虑安全性,做到"能发出去、能验得了、还能防破解"。

第一步 - 生成密钥对(私钥 和 公钥)

在正式做 License 授权之前,我们需要先准备好一对"密钥"------私钥和公钥。

这个步骤就像是在盖章之前,先准备好印章和验章的工具。

我们这一步选用的是 keytool 命令行工具来生成密钥对。

这是 Java 自带的工具,不用写一行代码,直接在终端里敲命令就能搞定,特别适合 Java 项目。

尤其是如果我们的团队本身就习惯用 keystore 来管理证书,那这个方式会非常顺手。

本次演示环境是 mac + JDK 1.8。


做授权机制的时候,最核心的目的之一就是:

防止用户伪造 License 文件。

为了实现这个目的,我们需要借助非对称加密:

  • 私钥:自己保存,只有拥有它的人才能"盖章"(签名 License)
  • 公钥:可以公开,分发给客户端,用来"验章"(验证 License)

有了这一对密钥,客户端就能准确判断当前 License 文件是否真的是我们发的,别人即使照着格式伪造一个,也验不过。

用 keytool 生成密钥库(包含私钥和公钥)

我们可以用下面这条命令来生成一个密钥库,里面自动包含了一对密钥(私钥和公钥):

bash 复制代码
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"

当然我们也可以按需改一下路径和密码参数。建议先在本地建个目录来放密钥文件,例如:

bash 复制代码
mkdir -p /Users/kaka/license_keys
cd /Users/kaka/license_keys

执行成功后,会生成一个 privateKeys.keystore 文件。这个文件就是我们的密钥库,包含了:

  • 一个私钥,用来生成和签名 License;
  • 一个配套的公钥,后续可以导出来给客户端使用。

关键参数解释一下

参数 说明
-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 文件里,公钥和私钥是放在一起的。

但在实际应用中,我们只希望客户端拿到 公钥,不能接触到私钥。

所以我们需要再做一步操作,把公钥从密钥库里导出来:

bash 复制代码
keytool -exportcert \
 -alias privateKey \
 -keystore privateKeys.keystore \
 -storepass pubwd123456 \
 -file certfile.cer

执行成功后,会生成一个 certfile.cer 文件,这就是标准的 X.509 公钥证书文件。

这个文件里只有公钥,不包含私钥,可以放心地给客户端使用。

那这个证书文件有什么用?

  • 它是一个标准的 .cer 公钥证书;
  • 它可以直接嵌入到客户端程序中;
  • 用来校验 License 文件是否合法;
  • 不存在泄露风险,完全可以公开分发。

如果我们还想更规范一点,可以把公钥单独导入到另一个 keystore 中(可选)

如果我们不希望客户端直接用 .cer 文件,而是希望它也加载 keystore,可以把公钥导入到一个新的 keystore 中:

arduino 复制代码
keytool -import \
 -alias publicCert \
 -file certfile.cer \
 -keystore publicCerts.keystore \
 -storepass pubwd123456

这样我们就得到了一个只包含公钥的 publicCerts.keystore 文件,适合客户端统一管理、集中校验多个公钥的场景。

不过如果只是做简单授权验证,一个 .cer 文件就够用了,不用非得这样做。

到这里,我们已经完成了:

  • 通过 keytool 生成了一份密钥库,里面有私钥和公钥;
  • 把公钥导出成了 .cer 文件,方便客户端使用;
  • 私钥我们留在本地,后面用于签发 License 文件;
  • 公钥我们公开,供客户端验证 License 的合法性。

下一步,我们将用私钥来生成授权文件,也就是 License 文件本体。


第二步:生成 License 文件(并用私钥签名)

完成了密钥对的准备工作之后,接下来就要进入真正的"授权阶段"了:

我们要用 私钥 来生成授权文件,也就是 License 文件

这一阶段我们做了什么?

在我们实际系统中,License 的生成过程不需要手动敲命令,而是通过后台系统来完成的。我们只需要:

  • 打开后台管理系统;

  • 选择客户、所属项目、模块包、有效期等授权参数;

  • 提交生成请求,后台会:

    • 整理这些授权信息;
    • 用私钥签名;
    • 生成 .lic 授权文件。

整个过程对使用者是无感知的,底层则实现了一整套签名与验证机制,确保生成的 License 具备防篡改能力。

那为什么一定要"签名"?

假设我们只是生成一个普通的 JSON 文件,写上"这个客户可以用到 2025 年",那任何人都能随便改内容、续费、甚至破解授权。

为了防止这种情况,我们需要引入数字签名机制

  • 后台在生成 License 的时候,会用私钥对内容进行签名;
  • 客户端加载 License 时,会用公钥来验证这份签名是否正确。

这样一来就能确保:

  • License 是我们官方签发的,不是客户伪造的;
  • 内容中途没有被人篡改过,哪怕改动一个字也会验证失败;
  • 客户端一旦发现签名无效,立即阻止程序运行或降级功能。

License 文件长啥样?

我们可以把 License 文件理解成一份"授权说明书",

它清晰描述了:客户是谁、授权时间、使用模式、有哪些功能权限、绑定哪些服务器等信息。

在我们的平台中,License 文件实际是一个结构化的 JSON 文件,大致像这样:

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 的签名逻辑很清晰:

  1. 后台将所有字段(除了 signature 本身)序列化成 JSON 字符串;
  2. 使用本地保存的私钥,对这个字符串进行加密,得到签名串;
  3. 把这个签名写入到最终的 signature 字段中;
  4. 保存成 License 文件,并返回给用户。

验证流程也很简单:

  • 客户端用公钥进行验签;
  • 只要内容被改过,签名就对不上,验证立即失败;
  • 没有正确签名的 License 文件一律视为无效。

文件格式怎么选?

在我们平台中,License 文件默认使用 JSON 格式进行保存,后缀为 .lic。这是因为:

  • JSON 格式通用性好,跨语言易解析;
  • 可读性强,便于调试和排查;
  • 搭配数字签名机制,即使内容被看见也无法伪造。

如果我们对接的是 Java 生态,当然也可以用 Properties 格式,配合 TrueLicense 读取。但本质逻辑一样:
先把内容转成稳定的文本格式,再进行签名写入,格式只是壳,签名才是关键。


第三步:搭建 Java 工程,准备项目骨架

前面我们已经通过 keytool 成功生成了一对密钥,现在就可以开始写真正的业务代码了。

但在写 License 的生成和验证逻辑之前,得先把整个项目跑起来,先搭好基本框架。

这一步的目标很简单:

创建一个能跑得起来的 Spring Boot 项目,把后面要用到的依赖、结构、目录提前准备好,方便我们逐步接入功能逻辑。

先说明一下实际架构情况:

在真实的业务环境中:

  • License 生成模块 是部署在我们自己的"业务中台后台管理系统"中,只有管理员才能访问,用于发证、补发、续期;
  • License 验证模块 是嵌入在每个客户的实际项目中,用来在程序启动或运行时做本地验证,控制权限和功能。

也就是说:
生成和验证代码, 在实际项目中是分开的两个工程,甚至部署在完全不同的机器上。

本文为了便于演示,我们在一个 Spring Boot Web 项目中把生成和验证功能都写在一起了,便于理解和调试。

实际使用时,需要把它们拆成两个独立模块来用。

一、我们都需要用到哪些接口?

虽然我们的授权系统看起来只是生成一个 .lic 文件,但在完整的业务链路中,其实会涉及到多个不同职责的接口。为了演示方便,我们这里统一放到了一个 Spring Boot 项目里,但真实的部署场景中,这些接口通常属于不同的模块或服务

1. /license/generate:生成 License 的接口(服务端使用)

这个是授权系统的核心接口,用于生成一份绑定客户信息和功能权限的 License 文件。

  • 实际部署中,这个接口一般只存在于我们公司的运营后台;
  • 后台人员根据客户的项目、功能模块包、授权时间、机器绑定等信息进行生成;
  • 生成过程使用的是我们自己的私钥进行签名,因此这个服务不能对外开放

2. /license/verify:验证 License 的接口(开发自测用)

这个接口不是必须上线的,它的主要作用是方便我们自己验证 License 文件是否正确,帮助开发人员在调试阶段快速判断授权内容是否合法。

  • 实际项目运行时不会走这个接口,而是由程序自动加载 .lic 文件并完成验证;
  • 验证结果会写入全局上下文,配合拦截器或配置中心控制功能权限;
  • 所以这个接口更像是一个"自测工具",上线后也可以选择关闭或限制权限。

3. /machine/info:采集目标服务器的硬件指纹

为了实现"机器绑定",我们在生成 License 文件时,通常会要求客户提供部署机器的一些唯一硬件信息,比如:

  • CPU 序列号(cpuSerial
  • 主板序列号(mainBoardSerial
  • 网卡的 MAC 地址(macAddress

这些字段构成了机器的"指纹",只有这些信息全部匹配,License 文件才会验证通过。

那客户怎么获取这些硬件信息?

我们对外提供了一个接口 /machine/info,客户可以在目标服务器上访问这个接口,获取一段 JSON 格式的机器信息。例如:

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 中。

这里我们只展示示例代码中需要用到的部分配置:

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 时用到的关键字段:

  • Redis 是为了后期做序列号生成、防重复、过期提醒等扩展用的;
  • 私钥、公钥路径分别用于签名与验证;
  • 客户端相关路径则用于本地加载 license 并做校验;
  • time-secret 是为了防止用户"时间回拨"作弊,做时间加密校验时使用的。

实际业务中我们都需要注意什么?

虽然我们这里用的是明文配置,但在真实项目中,直接写密码和路径是非常不安全的做法,有几个方面要特别注意:

  1. 包含敏感信息,不能直接暴露

    • 比如 key-passstore-pass、Redis 密码等;
    • 一旦泄露,等于把整个授权体系交到了别人手里。
  2. 配置文件不要直接上传到代码仓库

    • 哪怕是私有 Git 仓库,也建议加上 .gitignore
    • 通常这些配置,线上环境是从配置中心动态拉取的,不会写死在代码里。

那我们要怎么对这些敏感字段加密?

这里推荐使用 Jasypt 插件来做配置项加解密,Spring Boot 已经有成熟的集成方案了:

加密流程是这样的:

  • 首先,用工具把密码加密生成一段密文(比如 ENC(...));
  • 然后把密文写到配置里,比如:
json 复制代码
key-pass: ENC(kdsafljsdf0923jdf==)
  • 启动项目时,带上解密密钥参数:
json 复制代码
-Djasypt.encryptor.password=your-decrypt-key
  • 程序运行时,Jasypt 会自动把 ENC(...) 解密成明文,代码里用起来和原来一模一样。

小结一下

  • 授权服务的配置项不要乱写,敏感信息尽量加密;
  • 本地开发阶段可以先用明文跑通流程;
  • 上线之前记得统一接入加解密方案;
  • 配置文件别上传仓库,建议接入配置中心管理(Apollo、Nacos、Spring Cloud Config 等都可以)。

第四步:写生成 License 的核心代码

这一部分是整个授权系统的重头戏,也就是当客户发来授权申请时,我们服务端怎么去生成一个 .lic 文件。

License 文件里会包含客户信息、授权时间、绑定机器、功能开关等等,并且会用私钥对这些信息做一次签名,防止别人篡改。

1. LicenseRequest

这个类用来接收前端传过来的参数,比如项目 ID、客户名、开始时间、到期时间、功能开关、绑定的机器等等。前端发起授权申请的时候,就是填这些字段。

java 复制代码
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)、客户信息、授权时间段、功能模块配置、部署模式(单机/集群)、签名字段等等。

java 复制代码
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。

java 复制代码
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 来自增序号,保证唯一性。

java 复制代码
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
  • 用 ObjectMapper 把它转换成 JSON 字符串;
  • 加载本地 keystore 文件,读取私钥;
  • 用私钥对 JSON 做签名;
  • 把签完名的 JSON 保存成 .lic 文件,写到指定目录下。
java 复制代码
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 校验接口

这段逻辑提供了一个用于服务端主动验证 License 的接口,比如用于后台界面点击 "校验授权" 按钮时调用。

核心类:LicenseVerifierService

java 复制代码
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

java 复制代码
// 执行 License 校验,返回授权内容
LicenseContent license = verifier.verify();

// 将授权状态注入 LicenseContext,全局可用
LicenseContext.setVerified(license);

完整逻辑拆解如下:

1. LicenseVerifier

该类负责从配置中读取路径,加载 License 文件,并执行核心的校验流程:

java 复制代码
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)
java 复制代码
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") 判断某功能是否被授权。

java 复制代码
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 注入一个拦截器:

java 复制代码
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"         //    错误页面
                );
    }
}

核心校验逻辑在拦截器中完成:

java 复制代码
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") 来做开关判断:

java 复制代码
if (!LicenseContext.isFeatureEnabled("exportExcel")) {
return Result.fail("当前功能未授权,无法导出");

}

进一步的拦截优化

前面我们介绍了客户端如何接入授权校验,比如在服务启动时加载 License、注册全局状态,在每个请求前用拦截器判断有没有授权,还可以在功能模块里加一行代码判断某个功能有没有开通。

这些写法虽然直接,但其实也有一些不太方便的地方:

  • 要改的地方太多 :每个功能都要手动判断,哪怕只是导出个 Excel,也得自己加一行 isFeatureEnabled("exportExcel"),一不小心就可能漏掉;
  • 代码比较零散:授权相关的逻辑散落在 Controller、Service、拦截器等各种地方,维护起来比较混乱;
  • 不方便复用:我们这个项目接好了,下一个项目还得再复制粘贴一遍,不够通用。

所以我们可以做两点优化,把整套授权机制封装得更干净、好用、可复用。

1. 注解 + AOP:更优雅地控制权限

我们可以自定义一个注解,比如 @LicenseCheck("exportExcel"),然后通过 Spring AOP 在方法执行前自动进行权限判断。

这样用起来就很清爽:

java 复制代码
@LicenseCheck("exportExcel")
@GetMapping("/export")
public void exportExcel() {
    // 如果授权通过才会执行这里
}

不需要每个地方都手动写 LicenseContext.isFeatureEnabled(...),也不会有遗漏的风险。

2. 把授权模块单独封装成 SDK

除了写法更优雅,我们还可以把整套授权相关的逻辑(比如 LicenseVerifierLicenseValidatorLicenseContext、拦截器等)封装成一个独立的模块,比如单独建一个 license-sdk 项目,然后打包成 Jar,通过 pom.xml 引入到各个业务项目里。

这样做有什么好处?

  • 每个业务项目只需要引入依赖、开几个配置项,就能接入授权功能;
  • 避免重复造轮子,统一逻辑、统一维护;
  • 后续如果授权逻辑要调整,只需要改 SDK 一处地方,所有接入的项目都能统一升级。

由于篇幅限制 这里我就不再写具体的例子来哈

第六步、采集目标服务器的硬件指纹信息

为了确保授权文件只能在指定机器上运行,我们需要采集目标服务器的硬件指纹信息,包括 CPU 序列号、主板序列号、网卡的 MAC 地址等。这些信息会作为绑定内容写入到 License 文件中,后续客户端校验时就能判断当前机器是否是被授权的那台。

我们提供了一个接口 /machine/info,用于在目标机器上调用并返回当前机器的硬件信息。调用方式可以是:

json 复制代码
curl http://127.0.0.1:8081/machine/info

返回结构如下:

json 复制代码
{
  "cpuSerial": "0x000306a9",
  "mainBoardSerial": "MB-123456789",
  "macAddress": "A1:B2:C3:D4:E5:F6"
}

核心逻辑代码说明

这个接口背后的实现,核心就是调用系统命令行来读取机器的硬件信息。以下是示例代码片段:

java 复制代码
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 ID
  • dmidecode -t baseboard | grep Serial 获取主板序列号
  • 网络接口枚举中提取第一个可用的 MAC 地址

注意事项:

  • 这些命令依赖 dmidecode 工具,服务器可能需要 root 权限才能执行;
  • Windows 系统的命令需要另行适配(例如 wmic 指令);
  • 如果我们是部署到容器里运行,建议确保容器有权限访问这些底层信息,或者在宿主机上执行采集程序并传入结果。

其实我们可以把这个接口打包为一个单独的 jar 工具,交给客户在他们的机器上运行,收集到的信息可以通过后台系统上传或人工复制给我们,用于生成绑定的 License 文件。这样既保证安全,也方便授权流程闭环。是否要上传/记录到数据库,取决于我们后台系统是否需要支持续期、重发等能力。

接口测试效果一览

为了验证我们整个授权流程是否跑通,这里展示三组接口的测试截图和简单说明。

1. /machine/info:采集目标服务器的硬件指纹

这个接口的作用是从部署机器中采集唯一性信息,比如 CPU 序列号、主板序列号、MAC 地址等,用于后续和 License 文件做绑定与校验。

从测试截图可以看到,我们在本地 Mac 上调用 /machine/info 得到的结果如下:

由于 Mac 环境下 dmidecode 命令不可用,因此 CPU 和主板序列号为空,这属于正常情况。在 Linux 或 Windows 上运行时会有完整数据。

2. /license/generate:生成 License 的接口

这个接口是用来根据我们填写的客户信息、授权功能、绑定的机器等信息,来生成一个 .lic 文件。

我们这里传的参数是这样的(只展示关键字段):

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"
    }
  ],
  "contractId": "HT-202508-001",
  "receiptId": "FKSZ-202508-882"
}

也就是填清楚项目信息、功能开关、有效时间、授权机器这些。

请求发出后,会在本地生成一份 .lic 授权文件,实际的返回结果和生成效果如下:

这个就表示生成成功,路径下也确实有对应文件。后续我们就可以拿这个 License 文件去客户端验证使用了。

3. /license/verify:验证 License 的合法性

这个接口用于服务端或开发调试阶段快速验证一个 License 文件是否有效,是否与当前服务器信息匹配,是否过期等。

测试中我们传入一个生成好的 .lic 文件:

json 复制代码
GET /license/verify?licensePath=/Users/kaka/licenses/DOCX-TST-202509-001.lic

得到的结果如下图所示:

提示"硬件指纹不一致",说明当前服务器的机器码与该 License 文件绑定的机器不匹配。这种错误在真实部署中可能是:

  • License 被拷贝到别的机器使用;
  • 客户替换了硬件(如换主板);
  • 或者是生成时绑定信息写错。

代码太多就不全贴了

这篇文章写得已经挺长了,很多代码为了篇幅就没一一展示,比如完整的 License 验证流程、异常处理逻辑、拦截器的初始化方式等等。

如果有需要看一个相对完整点、可以跑起来的例子,我这边把上面写的伪代码逻辑放在 GitHub 上了,地址如下:

github地址

需要说明的是,这只是一个用来学习和参考的 Demo,主要目的是帮助大家搞清楚授权机制该怎么设计,不建议直接拿去上生产。如果真要用于实际项目,还需要做不少安全加固,比如加强加密、防篡改处理、异常兜底逻辑等。

可以先跑一跑,再根据自己的需求做调整,有兴趣可以看一下。


为什么要自己去搞这样的 License 授权机制?

说到底,其实市面上已经有很多成熟的 License 授权方案了,像 TrueLicense、JLicense、一些收费的 SDK 等等。如果只是简单做个授权,直接拿来用也完全没问题。

那为啥我还要自己搞一套呢?主要还是为了能更灵活地适配自己的业务场景。

一方面,有些客户的部署环境比较特殊,比如是私有化部署,或者是在局域网里运行的系统,那些需要联网授权的方案基本用不了,必须要能本地校验,甚至得适配集群、多台机器这种情况。

再一个,我们这边的授权内容也不只是控制"能不能用",还要细化到某个功能模块开不开、授权了哪些配置、有没有绑定合同号之类的,这些在现成工具里通常不支持,或者集成起来很麻烦。

还有就是安全性。有些工具对时间回拨、反篡改这些场景支持得不太够,但我们这边对安全是有要求的,像时间回拨校验、首次使用时间校验、本地记录文件的 HMAC 签名等等,这些都得我们自己做才放心。

最后就是维护成本的问题。我们这套机制做成了 SDK,各个系统都能直接接入,而且代码是我们自己写的,后续要改、要加字段、要对接平台都很方便,也不会出现外部库升级带来的兼容性问题。

所以总结一下,并不是现成方案不好用,而是我们想要的刚好不是"通用场景",而是那种"偏业务"、"偏安全"、"偏私有化"的定制化能力。与其勉强适配别人的,不如自己写一个更贴合需求的,前期花点时间,后续接入和维护都更舒服。

相关推荐
SimonKing6 小时前
亲测有效!分享一个稳定访问GitHub,快速下载资源的实用技巧
java·后端·程序员
这里有鱼汤6 小时前
量化小白必看|MiniQMT踩坑记:想做实盘这些知识请你一定要掌握
后端·python
TechLee6 小时前
Laravel 权限控制新选择:使用 Laravel-authz 集成 PHP-Casbin
后端·php
青梅主码6 小时前
量子位智库最新发布《 AI Coding 玩家图谱》: AI 编码玩家图谱全解析
后端
过期动态6 小时前
MySQL内置的各种单行函数
java·数据库·spring boot·mysql·spring cloud·tomcat
武子康6 小时前
大数据-85 Spark Action 操作详解:从 Collect 到存储的全景解析
大数据·后端·spark
唐天一6 小时前
Rust 基础之常用语法
后端
绝无仅有6 小时前
Go 语言面试之通道 (Channel) 解密
后端·面试·github
CodeSheep6 小时前
甲骨文严查Java授权,公司连夜删除JDK。。
前端·后端·程序员