离线部署 + 不可控环境,授权成了难题
我之前在一家公司做过一段时间自研产品的研发,做的项目类型比较多,既有内部系统,也有对外的产品,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;
这不就直接绕过验证了吗?我们做了两件事来防止这个问题:
- 代码混淆:使用 ProGuard 或 Allatori 等混淆工具,把方法名、变量名混乱化,让人看不懂。
- 代码加密:比如配合 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 算出来的)。
我们每次启动程序时,做三件事:
- 获取当前系统时间
- 读取
time-record.json
中记录的"上次启动时间" - 比较两者大小 ------ 如果当前时间比上次早,就报错并终止程序
这样一来,系统时间被回调就能被精准识别出来。
那如果客户直接删掉或者改这个记录文件呢?
我们也考虑了这种场景。
程序在处理 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,比如
exportExcel
、logMonitor
、crmIntegration
等; - 最终生成 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 的签名逻辑很清晰:
- 后台将所有字段(除了 signature 本身)序列化成 JSON 字符串;
- 使用本地保存的私钥,对这个字符串进行加密,得到签名串;
- 把这个签名写入到最终的
signature
字段中; - 保存成 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
是为了防止用户"时间回拨"作弊,做时间加密校验时使用的。
实际业务中我们都需要注意什么?
虽然我们这里用的是明文配置,但在真实项目中,直接写密码和路径是非常不安全的做法,有几个方面要特别注意:
-
包含敏感信息,不能直接暴露
- 比如
key-pass
、store-pass
、Redis 密码等; - 一旦泄露,等于把整个授权体系交到了别人手里。
- 比如
-
配置文件不要直接上传到代码仓库
- 哪怕是私有 Git 仓库,也建议加上
.gitignore
; - 通常这些配置,线上环境是从配置中心动态拉取的,不会写死在代码里。
- 哪怕是私有 Git 仓库,也建议加上
那我们要怎么对这些敏感字段加密?
这里推荐使用 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
除了写法更优雅,我们还可以把整套授权相关的逻辑(比如 LicenseVerifier
、LicenseValidator
、LicenseContext
、拦截器等)封装成一个独立的模块,比如单独建一个 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 IDdmidecode -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 上了,地址如下:
需要说明的是,这只是一个用来学习和参考的 Demo,主要目的是帮助大家搞清楚授权机制该怎么设计,不建议直接拿去上生产。如果真要用于实际项目,还需要做不少安全加固,比如加强加密、防篡改处理、异常兜底逻辑等。
可以先跑一跑,再根据自己的需求做调整,有兴趣可以看一下。
为什么要自己去搞这样的 License 授权机制?
说到底,其实市面上已经有很多成熟的 License 授权方案了,像 TrueLicense、JLicense、一些收费的 SDK 等等。如果只是简单做个授权,直接拿来用也完全没问题。
那为啥我还要自己搞一套呢?主要还是为了能更灵活地适配自己的业务场景。
一方面,有些客户的部署环境比较特殊,比如是私有化部署,或者是在局域网里运行的系统,那些需要联网授权的方案基本用不了,必须要能本地校验,甚至得适配集群、多台机器这种情况。
再一个,我们这边的授权内容也不只是控制"能不能用",还要细化到某个功能模块开不开、授权了哪些配置、有没有绑定合同号之类的,这些在现成工具里通常不支持,或者集成起来很麻烦。
还有就是安全性。有些工具对时间回拨、反篡改这些场景支持得不太够,但我们这边对安全是有要求的,像时间回拨校验、首次使用时间校验、本地记录文件的 HMAC 签名等等,这些都得我们自己做才放心。
最后就是维护成本的问题。我们这套机制做成了 SDK,各个系统都能直接接入,而且代码是我们自己写的,后续要改、要加字段、要对接平台都很方便,也不会出现外部库升级带来的兼容性问题。
所以总结一下,并不是现成方案不好用,而是我们想要的刚好不是"通用场景",而是那种"偏业务"、"偏安全"、"偏私有化"的定制化能力。与其勉强适配别人的,不如自己写一个更贴合需求的,前期花点时间,后续接入和维护都更舒服。