现象:禁用账号后旧令牌还能用
安全巡检时发现一个问题:把某个账号禁用、并把令牌版本号加一之后,按设计旧令牌应当立即失效。但实测中,旧的令牌在相当长的一段时间内仍能通过校验。
这不是 JWT 的"固有缺陷",而是我们的配置被写坏了。
根因:同一个配置键承担了两种语义
鉴权中间件里有一个"时钟容忍度"参数,用于容忍客户端与服务端之间的小幅时间漂移(正常取值是几十秒级别)。
授权模块上线时,为了处理"授权到期后给客户一个宽限期继续使用",也用了同一个配置键,写入了一个以"天"为单位的值。
于是:
- 鉴权模块按秒解释这个值 → 容忍度被放大到极大的范围;
- 结果:令牌的过期时间判断几乎失效;
- 而所有功能测试都通过,因为正常登录、正常调用接口完全不受影响。
这类缺陷的可怕之处在于:它不影响功能,只削弱安全边界。
为什么排查花了时间
三个原因:
- 配置值本身合法。读取时没有范围断言,任何数字都接受;
- 两个模块耦合在配置键上,代码里看不出调用关系,只有运行时才串起来;
- 测试用例都在验证"能登录""能调通",没有一条验证"过期就必须拒绝"。
修法:三件事同时做
第一,配置命名空间隔离。 鉴权参数统一放在 auth.* 下(如 auth.jwt_leeway),授权参数放 license.* 下。同名键不允许跨命名空间复用。
第二,参数范围断言。 关键安全参数在读取时校验范围:容忍度必须是 0~300 秒,超出即拒绝启动或回落默认值并告警。宁可"配置不生效",也不要"配置生效但方向错了"。
第三,补上针对性用例。 加三条:
- 令牌过期后调用接口必须 401;
- 账号被禁用后,未过期的令牌也必须被拒;
- 令牌版本号变更后,旧令牌立即失效。
这三条用例的价值远高于一堆"正常路径"测试,因为它们守护的是边界。
三条可复用的约束
这次事故之后,我们把它抽象成三条规则,写进开发规范:
- 安全相关参数必须带命名空间,禁止裸键名(如
leeway、timeout); - 参数取值范围要在代码里断言,不能只写在文档里;
- 鉴权、授权、加密三条链路的关键行为,必须有负向用例(应该失败的必须失败)。
一句话总结
"能跑通"和"边界是对的"是两件事。功能测试证明前者,负向用例才证明后者。一套要交付给客户长期运行的系统,后者的价值往往更高——因为安全边界一旦破了,不会有人主动告诉你。