Skip to content

Support stateless JWT validation#9569

Open
cn00 wants to merge 1 commit into
AlistGo:mainfrom
cn00:main
Open

Support stateless JWT validation#9569
cn00 wants to merge 1 commit into
AlistGo:mainfrom
cn00:main

Conversation

@cn00

@cn00 cn00 commented Jun 29, 2026

Copy link
Copy Markdown

JWT 本身支持无状态验证, 感觉 MemCache 有点多余了; 集群所有实例使用同一个 JWT_SECRET 并使用共享数据库;退出登录、踢设备、密码变更等失效能力现在主要依赖共享的 device session 表和 PwdTS, 这样就支持集群及 lambda 等 serverless 部署了, 爽歪歪

@okatu-loli

Copy link
Copy Markdown
Collaborator

感谢 PR。无状态化以支持集群/serverless 的动机是合理的,但目前的实现把"吊销能力"交给了一个还没准备好承担它的机制,合入前建议先处理下面几个问题。

核心问题

删除 validTokenCache 之前,这个白名单会在 ParseToken 层对所有入口(HTTP API、Authn、MCP)统一拒绝已注销的 token。删除之后,PR 声称接管吊销的 "device session + PwdTS" 机制在三处漏掉了这份职责,导致登出 / 踢设备在多数路径上实际不生效——除非改密码(PwdTS)或轮换全局密钥,否则被盗 token 会一直有效到自然过期(默认 48h)。

1. 登出 / 踢设备可被 Client-Id 绕过(最严重)

server/middlewares/auth.go:111 里 session 的 key 是 MD5(userID + Client-Id),而 Client-Id 完全由客户端请求头 / query 提供。登出只把这一个 key 标为 inactive,但 JWT 只携带 Username + PwdTS,不绑定任何设备。

攻击者登出后换一个(或省略)Client-Id,device.Handle 走到 GetSession 返回 ErrRecordNotFound;由于 MaxDevices 默认 0(不限设备),直接 CreateSession 新建一个 Active session,请求成功。被"登出"的 token 照常可用。

2. Authn 中间件根本不查 device session

server/middlewares/auth.go:154Authn(挂在 /api/authn/*:webauthn 注册/删除、getcredentials)只做 ParseToken + PwdTS + Disabled 校验,从不调用 HandleSession。加上 ParseToken 内原有的吊销检查已被删除,登出/踢设备对这些路由零效果。

具体后果:登出后攻击者持旧 token 调用 POST /api/authn/webauthn_finish_registration,可给受害者账号注册自己的 passkey,把限时的被盗 token 变成永久免密访问。

3. MCP 入口同样不查 device session

server/mcp/auth.go:68authenticateToken 也只有 ParseToken + PwdTS + Disabled。登出/踢掉全部设备后,旧 token 仍保有完整的 MCP 文件系统读写权限直到过期。

4. 吊销会在 token 过期前先失效

MarkInactive 只改 status、不更新 last_active(internal/db/session.go:68)。而 device.Handle 每次请求先执行 DeleteSessionsBefore(now - DeviceSessionTTL),默认 TTL 24h,token 默认 48h。登出 24h 后那条 inactive 记录被删,25h 时同一 token 再来 → GetSession NotFound → device.Handle 又新建 Active session,把已登出的 token "复活"。

5. 重启不再强制重新登录(行为回退)

旧 MemCache 只在 GenerateToken 时填充,进程重启后所有存量 token 因不在 cache 中而等价于被吊销。现在验证纯无状态,重启后所有 token 仍有效到过期,失去了"重启即强制重新认证"这一应急手段。

次要:空操作存根 + 死代码

InvalidateToken / IsTokenInvalidated 被掏空成常量(return nil / return false),但调用点仍在:

  • server/handles/auth.go:281 登出里检查 InvalidateToken 的 error 并可能返回 500——现已不可达;
  • server/middlewares/auth.go:115_ = common.InvalidateToken(token) 是纯空调用。

签名不变,编译器无法提示,未来贡献者调用 InvalidateToken 会误以为真的吊销了。IsTokenInvalidated 已无任何生产调用者。建议整体删除这两个 API 及其调用点,或至少去掉死的 error 分支。

新增测试 TestInvalidateTokenDoesNotBreakStatelessJWTValidation 的断言(IsTokenInvalidated()==falseInvalidateToken()==nil)针对无条件常量,永远通过、测不到任何东西,其有效部分与第一个测试重复;setupAuthTest 也可用 conf.DefaultConfig() 替代手搓的稀疏配置。

建议

保留无状态的同时补齐吊销:

  1. 把 JWT 绑定到服务端 session(claims 里放服务端生成的 session/device id,而非客户端自选的 Client-Id),否则问题 1 让整个 device-session 吊销形同虚设;
  2. 在 Authn 和 MCP 路径也执行 session 检查——把吊销校验下沉到 ParseToken 或抽一个公共 gate,而不是散落在 gin 中间件里,这样所有入口共享同一道关;
  3. 若要兼顾集群与即时吊销,更彻底的做法是 DB / Redis 支持的 denylist(存 jti + 过期时间,ParseToken 里查),而不是删掉吊销;
  4. 顺手清理空操作存根、死分支和同义反复的测试。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants