Skip to content

访问令牌验签通过之后,还要校验什么 ​

订单接口和账单接口用同一个身份服务签发的 JWT,资源服务器都只配置了公钥验签。测试时发现,拿着发给账单接口的令牌,订单接口也照样放行;再一查,前端登录拿到的 ID Token 直接塞进 Authorization 头,也能调通。

签名证明的是「这个令牌由持有私钥的一方签发,内容没有被改过」,它不回答「这个令牌是不是发给我的」「是不是访问令牌」「签发它的是不是我信任的那个身份服务」。这些问题要靠对声明(claims)和头部的逐项校验,而很多框架的默认配置只做了其中一部分。

本文用 Spring Security 7.1.1 的 JWT 解码器(底层是 Nimbus JOSE+JWT 10.9.1),自己签发一组令牌,对比默认配置与补齐校验后的结果。实验见文末。授权码流程本身的请求绑定见授权码、PKCE 与 state 各自防什么。

令牌默认解码器补齐 iss/aud/typtyp=at+jwt 的正确访问令牌拒绝(只认 JWT)接受受众是 billing-api接受拒绝签发者不同、同一把密钥接受拒绝ID Token(受众是网页应用)接受拒绝30 秒前过期接受(偏差 60 秒)接受没有 exp接受接受(需显式要求)另一把同 kid 的密钥拒绝拒绝alg=none / HS256 用公钥当密钥拒绝拒绝scope 是空格分隔的字符串:indexOf("orders.read") > 0 漏判首项、把 orders.readonly 误判为有权限;按集合精确匹配。scope 不表达对象归属,资源服务仍要做对象级授权本地验签的令牌在过期前不会因撤销而失效(撤销后内省为 false,本地验签仍接受)
图 1 · 实测(Spring Security 7.1.1、Nimbus 10.9.1):默认解码器管签名、算法与时间,不管受众和签发者,所以发给别的 API 的令牌、另一个签发者的令牌、ID Token 都被接受;它还只认 typ=JWT,按 RFC 9068 签发的 at+jwt 反而被拒。没有 exp 的令牌即使加了校验也被接受,要显式要求 exp。

一、默认解码器校验了什么 ​

java
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
令牌默认解码器
签发者、受众都正确接受
受众是 billing-api接受
签发者是另一个身份服务(同一把密钥)接受
一个 ID Token(受众是网页应用)接受
30 秒前过期接受
5 分钟前过期拒绝
没有 exp接受
另一把密钥签名(kid 相同)拒绝
alg=none 的未签名令牌拒绝
HS256,用公钥字节当密钥拒绝

默认解码器做好了两类检查:签名与算法(错误的密钥、未签名令牌、把公钥当 HMAC 密钥的算法混淆都被拒绝),以及时间(过期与生效时间,允许 60 秒的时钟偏差,所以 30 秒前过期的令牌还能通过)。它没有检查受众和签发者,开头的两个问题就出在这里。

签名只能证明令牌来自持有私钥的一方。如果同一个身份服务为多个 API 签发令牌,或者多个环境共用一套密钥,签名对所有这些令牌都成立;能把它们区分开的只有 aud 和 iss。

二、受众:令牌是发给谁的 ​

text
受众是 billing-api:加上签发者、受众、at+jwt 类型校验 拒绝:An error occurred while attempting to decode the Jwt: The aud claim is not valid

aud 声明说明令牌的目标接收方。资源服务器必须检查自己在受众列表里,否则发给任何一个 API 的令牌都能在所有 API 上使用:一个权限较低的服务拿到用户的令牌,就能转手调用权限更高的服务。

java
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
        new JwtIssuerValidator("https://idp.example"),
        new JwtAudienceValidator("orders-api"),
        new JwtTypeValidator("at+jwt")));

受众的取值要和身份服务的配置约定好:有的身份服务把客户端 ID 放进 aud,有的用资源标识符(RFC 8707 的 resource 参数)。

三、类型:ID Token 不是访问令牌 ​

text
一个 ID Token(受众是网页应用、带 nonce):默认解码器(typ=JWT)接受(aud=[web-app],iss=https://idp.example)

ID Token 是 OpenID Connect 发给客户端的认证结果,告诉网页应用「用户是谁」;访问令牌是发给资源服务器的授权凭据。两者都是同一个身份服务签发的 JWT,签名、签发者、有效期都可能通过校验。区分它们靠两件事:受众(ID Token 的 aud 是客户端 ID,不是 API),以及类型。

RFC 9068 规定 JWT 格式的访问令牌使用 typ: at+jwt,资源服务器可以据此拒绝其他类型的 JWT。这里有一个意外的结果:

text
typ=at+jwt、其余全部正确的访问令牌:默认解码器 拒绝:An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]

Spring Security 7.1.1 的默认解码器只接受 typ 为 JWT 或没有 typ 的令牌,按 RFC 9068 签发的访问令牌反而被拒绝。如果身份服务签发 at+jwt,资源服务器要显式配置类型校验器(或者使用 JwtValidators.createAtJwtValidator());反过来,如果身份服务签发 typ: JWT,就无法靠类型区分 ID Token 和访问令牌,只能依赖受众。

四、签发者、过期与必填声明 ​

text
签发者是另一个身份服务(同一把密钥):加上签发者、受众、at+jwt 类型校验 拒绝:… The iss claim is not valid
没有 exp:加上签发者、受众、at+jwt 类型校验 接受(aud=[orders-api],iss=https://idp.example)
没有 exp:再加上 setAllowEmptyExpiryClaim(false) 的时间校验器 拒绝:… exp is required
  • 签发者:多个身份服务、多个环境(测试与生产)共用密钥或密钥轮换时,iss 是区分它们的依据。从签发者地址自动发现配置时,框架通常会自动加上签发者校验;只配公钥时要自己加。
  • exp:JWT 规范里 exp 是可选的,默认的时间校验器在它缺失时放行。访问令牌应该总有过期时间,资源服务器可以要求它必须存在,防止签发端的配置错误产生永不过期的令牌。
  • 时钟偏差:默认 60 秒。偏差越大,过期令牌能多用的时间越长;越小,服务器时钟不准时越容易误拒。

五、scope:按集合判断,不按子串 ​

令牌里的 scope 是空格分隔的字符串,常见的错误是用字符串查找判断权限:

text
scope="orders.read orders.write":indexOf("orders.read") > 0 → false;按空格拆成集合精确匹配 → true
scope="profile orders.readonly":indexOf("orders.read") > 0 → true;按空格拆成集合精确匹配 → false

indexOf(...) > 0 同时犯了两个错:排在第一位的 scope 下标是 0,被漏判;orders.readonly 包含 orders.read 这个子串,被误判。按空格拆成集合再精确匹配才是对的;Spring Security 会把 scope 转成 SCOPE_orders.read 这样的权限,直接用它即可。

scope 表达的是「客户端被允许做哪一类操作」,不表达「这个用户能访问哪条数据」。即使网关已经校验了令牌和 scope,资源服务仍然要按令牌里的主体检查对象归属:用户 A 的令牌可以读订单,不代表可以读用户 B 的订单。

六、自包含令牌的撤销窗口 ​

JWT 在资源服务器本地验签,不需要每次请求都问身份服务,代价是撤销不能立即生效:

text
撤销一个访问令牌之后:内省 active=false;资源服务器用 JWK 本地验签:接受

(这一行来自授权码流程的实验。)令牌在过期之前一直有效。需要及时撤销的场景,可以缩短访问令牌的有效期、配合刷新令牌,或者对高风险操作改用内省;具体取舍见认证与系统集成。

七、检查清单 ​

  • 验签之外,校验 iss、aud、exp(要求存在)与时间偏差。
  • 用 typ 或受众区分访问令牌与 ID Token;身份服务签发 at+jwt 时,资源服务器显式接受它。
  • 解码器只允许预期的签名算法,不接受 alg=none。
  • scope 按集合精确匹配;对象级授权在资源服务里做。
  • 知道本地验签的撤销窗口,按风险选择有效期或内省。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权