主题
License 授权体系
仓库里同时存在两套互不相干的授权校验:老的
nc_licence(RSA 密文 + 5 秒轮询 + 产品号绑定的"证书")和新的nc_license(明文表 + 规格阶梯 + 业务数上限的"License 套餐"),配 License 必须两套都配。
1. 两套机制总览
| 维度 | A 路:nc_licence | B 路:nc_license |
|---|---|---|
| 包 | com.netease.is.nc.base.licence | com.netease.is.nc.base.license |
| 服务实现 | LicenceServiceImpl | LicenseServiceImpl |
| 表 | nc_licence | nc_license |
| 存什么 | licence_content = RSA 密文的 hex 字符串 | 全部字段明文(客户、套餐、起止时间、最大业务数) |
| 判过期依据 | 密文解出 JSON 里的 e(毫秒时间戳) | 表列 effective_start_time / effective_end_time(毫秒) |
| 产品绑定 | 是,JSON 里 p 必须等于库中第一个产品号 | 否 |
| 刷新方式 | 单线程每 5 秒轮询全量重载到内存 Map | 无缓存,每次判定直接 selectOnlyOne 查库 |
| 消费方 | LicenceCheckInterceptor(详见 §2.2 的现状说明) | GetCaptchaService、AppController、FrequencyController、AppServiceImpl、SysConfLoader |
| 后台页面 | licence/licence.html → /licence/*.json | 无页面,仅 /license/save.json、/license/private/query.json |
两套代码类名只差一个字母(Licence / License)、包名只差一个字母,注入时靠类型区分(licenceServiceImpl / licenseServiceImpl),不会互相覆盖;但它们读两张不同的表、判两套不同的规则——只配一张表,另一条链路照样失败。
2. A 路:nc_licence + 证书拦截
2.1 数据结构与轮询
表结构(captcha-base/src/main/java/com/netease/is/nc/base/licence/Licence.xml:18-20):id、status、create_time、update_time、operator、licence_content、valid_to。
LicenceServiceImpl 在 @PostConstruct 里起单线程调度器,初始延迟 1000 ms、周期 5000 ms(captcha-base/src/main/java/com/netease/is/nc/base/licence/LicenceServiceImpl.java:85-91)。每轮 loadLicence() 做两件事:
- 先失效:遍历内存 Map,凡
e < now的证书执行update nc_licence set status = 2 where licence_content = #{licenceContent}(同文件:93-100;SQL 见Licence.xml:113); - 再全量重载:
select全表 → 逐条rsaDecryptByPk(Hex.decodeHex(licenceContent))→JSON.parseObject放进新 Map(同文件:101-118;解密语句:106)。解析失败的条目只打日志、不进 Map,不会中断其他证书。
判定入口只有一个:
java
@Override
public boolean queryHaveLicence() {
for (Map.Entry<String, JSONObject> entry : licenceMap.entrySet()) {
boolean isValid = entry.getValue().getLong("e") > System.currentTimeMillis();
if (isValid) {
return true;
}
}
return false;
}(captcha-base/src/main/java/com/netease/is/nc/base/licence/LicenceServiceImpl.java:120-129)
即"任一条未过期即视为有授权",与产品、验证码类型无关。
2.2 拦截器现状(关键)
LicenceCheckInterceptor 存在于 captcha-server/src/main/java/com/netease/is/nc/server/common/interceptor/LicenceCheckInterceptor.java:20,逻辑是 queryHaveLicence() 失败即写出 ErrorCode.LICENCE_ERROR(111, "无效证书") 并中断(:26-34,错误码定义在 captcha-server/src/main/java/com/netease/is/nc/server/abst/ErrorCode.java:13)。
但它没有被注册到任何 MVC 配置:captcha-server/src/main/resources/server-application-context.xml 内无 <mvc:interceptors>,全仓检索 LicenceCheckInterceptor 只命中该类自身。也就是说 A 路目前是一个"定义完整但未接线"的校验点——/api/** 并不会因为 nc_licence 缺失而返回 111。真正会让取图失败的只有 B 路(§3.2)。把它接上是一次配置变更,需评估对现网的影响面后再动。
3. B 路:nc_license + 能力/规格校验
3.1 字段
(captcha-base/src/main/java/com/netease/is/nc/base/license/License.xml:23-39)
| 列 | 含义 | 校验中的作用 |
|---|---|---|
customer_account / customer_name | 客户账号 / 名称 | 仅展示;导入时非空强校验 |
business_type / package_type | 业务类型 / 套餐类型 | 仅展示;导入时非空强校验 |
captcha_type_specification | 规格码:BASE / COMMON / ADVANCED | 决定可用验证码类型集合与是否允许"增强" |
max_business_num | 最大业务(应用)数 | canCreateBusiness 用 |
effective_start_time / effective_end_time | 生效起止(毫秒) | 过期判定 |
pub_key / private_key | 该条 License 自带的 RSA 密钥对 | 生成 license 文件时用私钥加密 AES key |
3.2 校验逻辑
canUseCaptcha(captchaType, typeEnhance) 三步(captcha-base/src/main/java/com/netease/is/nc/base/license/LicenseServiceImpl.java:132-154):
checkLicenseValid:now < start || now > end即抛license is expired(:204-214);- 类型集合包含判断:
getCaptchaType(license).contains(captchaType); - 若
typeEnhance为增强,则规格必须是ADVANCED,否则 false。
getCaptchaType 的 switch 刻意没有 break,形成阶梯式向下兼容(:219-241):
| 规格码 | 可用类型 | 数量 |
|---|---|---|
BASE | SMART、DRAG、CLICK、ICON_CLICK | 4 |
COMMON | 上面 4 个 + IMAGE_PUZZLE、WORD_ORDER、SPACE、VOICE | 8 |
ADVANCED | 上面 8 个 + AVOID、WORD_GROUP | 10 |
canCreateBusiness(businessNum) 是 businessNum < maxBusinessNum(:157-167),严格小于,且用"当前应用总数"比对。
调用点(全部为强校验,失败即中断业务):
| 调用点 | 场景 | 位置 |
|---|---|---|
| 取图 | Validate.isTrue(licenseService.canUseCaptcha(...)) | captcha-server/src/main/java/com/netease/is/nc/server/service/GetCaptchaService.java:103 |
| 语音取图 | 同上,固定 VOICE + TypeEnhance.DEFAULT | captcha-server/src/main/java/com/netease/is/nc/server/api/GetControllerV2.java:207 |
| 后台保存应用 | 不支持则拒绝,并额外校验增强与业务数 | captcha-admin/src/main/java/com/netease/is/nc/admin/app/AppController.java:273-287 |
| 后台保存高频切换 | 切换目标类型必须在授权范围内 | captcha-admin/src/main/java/com/netease/is/nc/admin/freq/FrequencyController.java:191 |
| 应用音频开关 | 打开语音前校验授权 | captcha-base/src/main/java/com/netease/is/nc/base/app/AppServiceImpl.java:101、:166 |
| 智能无感兜底配置 | 不支持的类型权重直接置 0 | captcha-base/src/main/java/com/netease/is/nc/base/sys/conf/SysConfLoader.java:688-713 |
关于"60 秒刷新":B 路代码里没有任何缓存,selectOnlyOne(License.xml:129-134,limit 1)每次判定都查库,改库后下一次请求即生效;与之相邻的 30 秒是 SysConfLoader 自己的配置轮询周期(SysConfLoader.java:44)。把 B 路的生效延迟理解为"即时"更贴合代码。
4. RSA 密钥与加解密
4.1 A 路:硬编码公钥,签发方持私钥
- 公钥常量
PUBLIC_KEY(X.509 / Base64,2048 位)硬编码在captcha-base/src/main/java/com/netease/is/nc/base/utils/LicenceHelper.java:26; - 解密:
Cipher.getInstance("RSA/ECB/PKCS1Padding")+X509EncodedKeySpec,见LicenceHelper.java:53-75; - 输入是 hex(
Hex.decodeHex(licence.toCharArray())),输出是明文 JSON 的 UTF-8 字节; - 私钥只在签发侧(易盾商务/授权系统),仓库里没有、也不需要。
4.2 B 路:三层封装,公钥来自配置
导入(解密)用 license.publicKey(captcha-base/src/main/resources/base.properties:62,默认空;由 LicenseServiceImpl.java:49-50 注入),导出(生成 license 文件)用该行 License 自带的 private_key。
java
private License decryptRes(String res) throws Exception {
// 第一层:简单(异或)解密
String fileContent = CryptUtils.simpleDecrypt(res);
// 第二层:取出 RSA 密文的 key 与 AES 密文的 content
EncryptLicense encryptLicense = JSON.parseObject(fileContent, EncryptLicense.class);
String key = encryptLicense.getKey();
String content = encryptLicense.getContent();
key = RSAUtils.decryptByPublicKey(publicKey, key);
// 第三层:AES 解出 License JSON
String decrypt = decrypt(content, key);
return JSONObject.parseObject(decrypt, License.class);
}(captcha-base/src/main/java/com/netease/is/nc/base/license/LicenseServiceImpl.java:261-271)
对应生成方向是镜像操作:随机 8 位数字作为 AES key → AES 加密 License JSON → 用 license.getPrivateKey() RSA 加密该 key → 组装 {key, content} → xorEncrypt 包裹(同文件 :273-293)。因此 license.publicKey 只影响"导入解密",不影响运行期判定——这是直接写库可行的前提。
4.3 B 路 license 文件外形
导入前先做文本级校验与清洗(LicenseServiceImpl.java:355-370):
| 步骤 | 规则 | 常量 |
|---|---|---|
| 头尾 | 必须 startsWith 头、endsWith 尾 | -----BEGIN CAPTCHA LICENSE----- / -----END CAPTCHA LICENSE-----(:44-45) |
| 去壳 | 删头、删尾、删 \r\n、删 \n、空格替换为 + | extractParameter |
| 排版 | 生成时每 64 字符换行 | LEN = 64(:46、:343-353) |
解出的明文 JSON 即 License 实体字段(FastJSON 默认驼峰),示例:
json
{
"id": 1,
"customerAccount": "ts-sss",
"customerName": "某某银行",
"businessType": "finance",
"packageType": "enterprise",
"captchaTypeSpecification": "ADVANCED",
"maxBusinessNum": 20,
"effectiveStartTime": 1735660800000,
"effectiveEndTime": 1767196800000
}A 路的明文 JSON 极简,只需要两个键(校验逻辑见 captcha-admin/src/main/java/com/netease/is/nc/admin/licence/LicenceController.java:98-114):
json
{ "p": "<产品号,须等于库中第一个产品 ID>", "e": 1767196800000 }5. 后台导入流程
5.1 A 路上传(唯一有 UI 的路径)
页面 captcha-admin/src/main/webapp/WEB-INF/view/licence/licence.ftl:164 以 multipart 方式 POST 到 /licence/save.json(:15 提供多文件选择),后端 LicenceController.save 依次:读文件文本 → Hex.decodeHex → LicenceHelper.rsaDecryptByPk(硬编码公钥)→ 校验 p / e 齐全、e 未过期、p 等于 productService 查询结果第一条的 product_id → insert 落库(同文件 :74-124)。
text
浏览器 LicenceController LicenceHelper nc_licence LicenceServiceImpl
| POST upload | | | |
|------------------>| content = 文件文本 | | |
| |--Hex.decodeHex------->| | |
| |--rsaDecryptByPk------>| RSA/ECB/PKCS1 | |
| |<----明文 JSON---------| | |
| | 校验 p/e 非空 insert | |
| |------------------------------------------>| 每 5s 全表 select |
| |<-------------- OK / 证书无效 --------------| |
|<--- StatusMsg ----| | |----> licenceMap |
| | | | queryHaveLicence |两个硬约束:p 必须与"当前库中第一个 status=1 的产品号"完全一致(LicenceController.java:80-114),多产品环境下极易失败;文件按 new String(data, 0, read) 逐块拼接读取(:139-153),编码异常会静默产出脏内容。
5.2 B 路导入(API 有、页面无)
/license/save.json → LicenseController.save → LicenseServiceImpl.save(jsonRsp)(captcha-admin/src/main/java/com/netease/is/nc/admin/license/LicenseController.java:34-46、captcha-base/.../license/LicenseServiceImpl.java:110-121):文本清洗 → 三层解密 → checkLicense(插入时也要未过期,:249-259)→ deleteById(id) 防主键冲突 → insert → deleteByNotId(id) 保证全表只剩一行。
关键事实:captcha-admin/src/main/webapp 下检索 /license/ 零命中,即这条导入链路没有前端页面;同时 spring-security.xml 未给 /license/* 声明 authority,按顺序落到 /** → hasAuthority('USER')(captcha-admin/src/main/resources/spring-security.xml:201),任何登录用户都能调用。要正式使用应先补页面与权限声明。
6. 直接写库的可行边界
| 目标表 | 能否直接写 | 需要什么 | 生效时间 |
|---|---|---|---|
nc_licence | 能,且不需要 license.publicKey | 必须有签发方私钥产出 hex(RSA(JSON{p,e})),或从其他环境复制有效 licence_content(轮询只校验 e,不看 p) | ≤5 秒(下一次轮询) |
nc_licence 置失效 | 能 | update nc_licence set status = 2 只是登记;真正判定看内存 Map 里的 e,需配合让 e 过期 | 立即(Map 重载后) |
nc_license | 能,推荐 | 直接 insert 一行明文(captcha_type_specification 必须是 BASE/COMMON/ADVANCED 之一,时间为毫秒),并保证表内只有一行(selectOnlyOne 取 limit 1) | 立即(每次请求查库) |
nc_license 改时间窗 | 能 | update ... set effective_end_time = ... | 立即 |
结论:B 路是私有化交付里最省事的一条——写一行明文即可,不涉及任何密钥;A 路必须拿到授权方私钥,硬编码公钥无法绕过。反过来说,license.publicKey 留空不阻塞启动,只让 B 路的"文件导入"不可用。
7. 校验失败表现
| 场景 | 表现 | 证据 |
|---|---|---|
| B 路取图缺授权 | Validate.isTrue 抛异常 → AbstractController.handleThrowable 兜底成通用错误响应(CaptchaError.EXCEPTION),前端只看到失败、看不到原因 | GetCaptchaService.java:103、captcha-server/src/main/java/com/netease/is/nc/server/abst/AbstractController.java:96-102 |
| B 路语音 | 同一兜底,日志里可见"license不支持语音验证码" | GetControllerV2.java:207 |
| B 路后台保存应用 | 明确中文提示:不支持的验证码类型. / 不支持验证码增强 / 验证码产品超过限制 | AppController.java:273-287 |
| B 路后台保存高频规则 | 保存被拒 | FrequencyController.java:191 |
| 规格降级 | 智能无感兜底配置里不受支持的类型权重被置 0,表现为"某类型再也不出现"而非报错 | SysConfLoader.java:705-714 |
| A 路缺证书 | 设计上返回 {"code":111,"msg":"无效证书"} 的 JSONP;当前因拦截器未注册而不触发 | LicenceCheckInterceptor.java:26-34、ErrorCode.java:13 |
| A 路上传非法证书 | 页面提示"证书无效"(三种原因同一文案:缺 p/e、已过期、产品号不匹配) | LicenceController.java:98-114 |
| B 路导入文件非法 | 抛 license is invalid / license is null / license is expired,LicenseController 只记 warn 并返回通用 ERROR | LicenseServiceImpl.java:355-362、LicenseController.java:39-45 |
8. 交付检查清单
- 两套表都要有数据:
select * from nc_licence至少一条e未过期、select * from nc_license恰好一条且时间窗覆盖上线期。 captcha_type_specification与实际售卖类型对得上——它是"规格阶梯",写BASE时拼图/文字点选统统不可用。max_business_num要大于等于交付应用数,否则后台建应用直接报"验证码产品超过限制"。- 时间统一用毫秒;
nc_licence只认密文里的e,改valid_to列不生效。 - 想让 A 路真正生效,需要新增拦截器注册(把
LicenceCheckInterceptor挂到/api/**),这是一次行为变更,必须先评估对现网取图的影响。