乐于分享
好东西不私藏

AI化日记·1:给老系统加 API Key 认证,实验正式开始

AI化日记·1:给老系统加 API Key 认证,实验正式开始

一个 Java 1.8 + Shiro 的老项目,要给 AI Agent 开放接口。东西不难 —— 一个 Filter、几个接口、一页前端。但这些活实际是在 Claude Code 里一天干完的:Claude Code 先用各种技巧把跑通了,Cursor 评价了一句「架构不对」,于是 Claude Code 就按 Shiro 多 Realm 推倒重来。三个版本、六个坑都挤在一个下午。用两个 AI 互相监督,比押宝在单一对话里要省心得多。


背景

上一篇文章里,我宣布了一个实验:用 CLI + Skills 模式,把 Athena 数据工厂改造成 AI 可操作的平台。1000 块钱预算,100% Vibe Coding,邀请大家云监工。

这篇文章就是云监工系列的第一份进展报告。

Athena 是公司数据工厂平台,Java 1.8 + Spring Boot + Shiro + Vue 2,跑了五年。改造的第一步是给后端加 API Key 认证:用户在前端页面申请 Key,CLI 带着 Key 发请求,后端识别后放行。

东西不复杂。但真的做起来,事情就不一样了。


V1:一个下午写完,一个坑一个坑地填

第一回合:Java 版本不兼容

Key 的生成很简单——athena_ 前缀 + 随机字符串。我们写了:

StringapiKey="athena_"
 + HexFormat.of().formatHex(bytes);

提交,构建,报错。java.util.HexFormat 是 Java 17 才有的,项目是 Java 1.8。

换成 javax.xml.bind.DatatypeConverter.printHexBinary(bytes)。修掉。

第二回合:Filter 根本没被调用

后端全套写完——Entity、Mapper、Service、Controller、Filter。部署,curl 测试:

→ {"code":401,"msg":"invalid token"}

看日志,Filter 没有任何输出。没被调用。

原因:Filter 用 @Component 自动注册,但项目通过 ShiroFilterFactoryBean 管理 Filter 链,把 @Component 注册的 Filter 盖掉了。

修:在 ShiroConfig 里用 FilterRegistrationBean 显式注册,order=1 保证跑在最前面。

第三回合:依赖注入失效

Filter 跑起来了,但 athenaApiKeyService 和 sysUserService 全是 null

我们 FilterRegistrationBean 的 Bean 方法里 new ApiKeyAuthFilter() 创建实例,@Autowired 字段不会自动注入——自己 new 的对象没走 Spring 容器。

修:改为 setter 注入,在 @Bean 方法的参数里让 Spring 注入依赖,再手动 set 进去。

第四回合:Key 查到了,用户找到了,但还是 401

日志长这样:

[ApiKeyFilter] 查询结果: found, userId=7198[ApiKeyFilter] 用户查询结果: found[ApiKeyFilter] 认证成功: userName=马云起[OAuth2Filter]  请求失败, 无效Token       ← 又 401

Filter 内部全部成功。但请求继续沿链路走,到了 Shiro 的 OAuth2Filter。OAuth2Filter 发现请求里没有它认识的那个 token 参数,直接返回 401——把我们刚完成的认证给盖了。

两个认证体系在一条链里互不认账。 这是最头疼的坑。

我们当时的修法:API Key 认证成功后,劫持 request 往 Header 里注入一个假的 token 参数,让 OAuth2Filter 能读到。同时写 sys_user_token 表,让 OAuth2Realm 能校验通过。还得判断用户是否已有 web session,避免覆盖已有的登录态。

终于通了。

第五回合:前端的 webpack 缓存

前端部署后报 Cannot find module——文件在正确位置,但 webpack 的动态 require 上下文模块没包含新建的文件。重启 dev server 解决。


V2:同事一句话,推倒重来

ps:此处的同事,就是Cursor;

V1 跑通后,我们觉得任务完成了。但同事看了代码,说了一句话:

"更稳妥的架构是用 Shiro 的多 Realm / 多认证方式,让 API Key 成为一等公民,而不是伪造 OAuth2 的 token 头。"

醍醐灌顶。我们的 V1 方案本质上是个补丁——在 Servlet Filter 层截胡请求,然后骗过后面的 Shiro Filter。功能对了,但架构姿势错了。

Shiro 本身就支持多认证方式。正确的做法是在 Realm 层让 API Key 和 OAuth2 Token 平起平坐,而不是在 Filter 层伪造。

V1 vs V2 架构差异

V1 — 补丁方案:绕过 Shiro,在外部截胡

┌──────────────────────────────────┐│  Servlet Filter                 ││  (ApiKeyAuthFilter)             ││                                 ││  ① 查 athena_api_key 表         ││  ② 查 sys_user 表               ││  ③ 往 sys_user_token 写假 token ││  ④ HttpServletRequestWrapper    ││     伪造 token header           ││  ⑤ 骗过 OAuth2Filter            │└──────────────────────────────────┘

V2 — 多 Realm 方案:融入 Shiro,让 API Key 成为一等公民

Shiro Filter Chain├─ ApiKeyAuthenticatingFilter ───┐│  ├─ 无 X-API-Key?→ 放行      ││  └─ 有 X-API-Key?            ││      → ApiKeyToken            ││      → executeLogin           ││         │                     ││         ▼                     ││      OAuth2Realm              ││      ├─ ApiKeyToken?         ││      │   → 查 athena_api_key  ││      └─ OAuth2Token?         ││          → 查 sys_user_token  ││                               │└─ OAuth2Filter                 │   └─ Subject 已认证?→ 放行    │                                │        两个 Token 平起平坐 ◄───┘

四个新文件,两个改动

• 新增 ApiKeyToken(Shiro AuthenticationToken)——API Key 作为一等凭证

• 新增 ApiKeyAuthenticatingFilter(Shiro AuthenticatingFilter)——80 行,排在 oauth2 前面

• 修改 OAuth2Realm——supports() 和 doGetAuthenticationInfo() 同时处理两种 Token

• 修改 OAuth2Filter——onAccessDenied 开头加一行:如果 Subject 已认证就直接放行

• 删除 ApiKeyAuthFilter(132 行)+ FilterRegistrationBean

第六回合:又漏了一步

V2 部署,500。日志:

API Key 认证成功: userId=7198NullPointerException at AthenaApiKeyController.listMyKeys

Realm 认证成功了,Filter 也通过了,但 Controller 的 @CurrentInstUser InstUser user 是 null。

OAuth2Filter 的 onLoginSuccess 会把用户信息注入到 request attribute,我们的 ApiKeyAuthenticatingFilter.onLoginSuccess 没写这步。加一行 request.setAttribute(PassportConstants.CURRENT_INST_USER, instUser),解决。


跑通

# 正确的 Key → 正常返回
$ curl -H "X-API-Key: athena_802a4..." https://beta/instance/taskRunState{"code":0,"msg":"success","data":[...]}   ← 200 OK# 改一个字符 → 拒绝$ curl -H "X-API-Key: athena_802a4...3" https://beta/instance/taskRunState{"code":401,"msg":"invalid token"}         ← 拒绝# 不带 Key → 拒绝$ curl https://beta/instance/taskRunState{"code":401,"msg":"invalid token"}         ← 拒绝

前端页面:创建 Key(仅一次完整展示)、查看列表(脱敏 Key)、吊销(软删除)。权限严格隔离:只能管理自己的 Key。


回头看

从"加个 Filter"到最终落地,经历了三个版本:

版本
做法
结果
V0
@Component
 自动注册
Filter 不执行,被 Shiro 覆盖
V1
FilterRegistrationBean
 + 伪造 token header
功能通了,架构不对
V2
Shiro 多 Realm,API Key 一等公民
功能通 + 架构对

最大的教训不是具体的技术坑,而是一个原则:在已有的认证体系上叠加新方式,不要绕过去,要融进去。

用 Servlet Filter 绕过 Shiro、伪造 token——能做,但它是补丁。用 Shiro 自己的多 Realm 机制、让 API Key 和 OAuth2 平起平坐——这才是正确的扩展方式。

同事那句话本质上是:"别在别人的体系外面搭棚子,到体系里面去,用体系自己的规则玩游戏。"


下期预告

API Key 认证只是第一步。接下来要把 Athena 的 100 多个 REST API 逐条封装成 CLI 子命令——instance list、task rerun、instance kill……这才是体力活。桥搭好了,该铺路了。

下期见。


往期回顾

• 《搞事情:1000 块钱,0 行手写代码,我要把一套数据治理平台 AI 化了》 —— 云监工系列序章

• 《当 SaaS 们都学会了 AI,下一步是什么?》 —— 为什么要做 AI 可操作化

• 《lark-cli:企业 SaaS 走向 AI 化时,值得对照的一份范本》 —— 参考架构怎么设计的

• 《让 AI 操作老系统:只需要 CLI 和 SKILLS》 —— 我的具体方案

不写代码的程序员。