认证与令牌上下文:JWT 无状态主体与线程级缓存的越权事故复盘
一篇关于身份的立论文:身份的难题不在认出谁,而在让每一行代码在同一时刻得到同一个答案——线程缓存与清理协议是同一回事的两面。
附件
- fig-token-context-defense.png(205 KB)下载
认证与令牌上下文:JWT 无状态主体与线程级缓存的越权事故复盘
「解铃还须系铃人。」令牌由请求系上,也必须由请求解下——这不是纪律条文,而是一场真实幽灵越权事故换来的结构自觉。本篇复盘认证与令牌体系的设计,与那次 403→200 的静默翻车。
一、界定:主体是令牌的,上下文是线程的
先把两个易混概念分开:主体(身份)是无状态事实——JWT(HS256,载荷 sub / sid / uname / iat / exp,15 分钟短寿)申明「现在是谁」;上下文是线程内的取用机制——TokenContext 懒加载缓存令牌解析结果,同一请求内重复调用不再重复解码。
请求进入 → TraceIdFilter
├─ TraceId.getOrCreate() (MDC)
└─ TokenContextProvider.getUserId()
├─ 首次调用:解析 Bearer JWT → 写入线程缓存
└─ 同请求再次调用:直接读缓存
请求结束 → finally:TraceId.clear() + TokenContext.clear()二、为什么必须:幽灵越权(事故复盘)
事故这样发生:TokenContext 是线程级缓存,而 Tomcat 复用工作线程。某个请求结束没有清理上下文,下一个匿名请求恰被分到同一线程,白捡了残留的身份——权限校验从 403 变成 200。一场幽灵越权,无攻击者、无异常、无声无息;它可怕在是静默的:日志里什么都没有,因为它根本不算事件。修复随之成为结构:TraceIdFilter 的 finally 负责清理(新增任何请求级 ThreadLocal 必须在同点清理,并由单测覆盖「同线程二次请求」);领域层审计钩子统一 orElse(null),不许空令牌冒名主体。
refresh token 是同一矛盾的另一面:access 令牌无状态不可撤销,于是撤销与轮换走有状态——随机生成、只存 SHA-256 哈希于 Redis,refresh 拒绝事件进认证审计。
三、机理:跨进程的第二把锁
跨 Dubbo 调用时,上下文传播收口为单一 attachment envelope(key:drizzlepal-rpc-context),DubboAttachmentContext 统一代理读写——consumer 只写、provider 只读,业务代码禁用散装 RpcContext 操作。序列化白名单只放行共享分层异常包,业务异常原样穿透;auth 域 AuthApiService 只做授权信息查询(角色/权限/资源解析),「我是谁」永远不跨域去问。
四、思辨:无状态的便利,有状态的义务
JWT 的一切优点(自包含、无查询、天然分布式)伴随一个致命缺点:签发后无法作废。工程以 15 分钟短寿控制风险窗口、以 refresh 哈希承担撤销义务——这不是折中的诗句,而是认账:没有免餐的安全性,只有被明确分配过位置的风险。解铃还须系铃人:由请求系上的上下文必须由请求解下;由系统发出去的身份,就必须有办法收回。
五、回到现实:三条可携带
- 新增请求级 ThreadLocal,必须在同一点清理并写「同线程二次请求」的单测;
- 信任边界上「谁是当前用户」一律取 token 中的 userId,不查 username、不跨域求证;
- 跨进程上下文只认单一 envelope key——第二个 attachment key 出现之日,就是透传失控开始之时。