1. 从默认令牌到自定义JWT:这次改造到底在解决什么问题
做过微服务认证授权的朋友大概都有过这种体验:用 Spring Security OAuth2 搭好一套授权服务,跑通密码模式、授权码模式之后,默认返回的令牌是一串毫无规律的 UUID 字符串,类似a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx。这串东西本身没有任何含义,服务端拿到它之后,还得回头去令牌存储(不管是内存、Redis 还是数据库)里查一次,才能知道这个令牌对应哪个用户、有哪些权限、什么时候过期。
这套机制在小规模场景下完全够用,但只要服务实例一多、并发一上来,问题就暴露了:每次资源服务校验令牌都要访问一次集中式存储,令牌存储成了整个链路里最容易被压垮的单点。更麻烦的是,默认令牌格式是框架内部约定的,你想在令牌里塞点业务字段,比如用户所属租户、登录设备标识、角色编码列表,几乎无从下手。
JWT(Json Web Token)就是冲着这两个痛点来的。它把用户身份、权限、过期时间这些信息直接编码进令牌本身,用签名保证不可篡改,资源服务拿到令牌后本地验签就能完成校验,不需要再回源查询。而 SpringCloud 和 SpringBoot 2.0 这套组合,恰好是当年微服务架构落地最主流的选型,OAuth2 作为授权标准,JWT 作为令牌载体,三者整合是很多团队绕不开的一道坎。
这篇内容面向的是已经跑通过基础 OAuth2 流程、想把默认令牌换成自定义 JWT 格式的开发者。我会把改造过程中涉及的签名算法选择、令牌增强器(TokenEnhancer)的写法、JWT 转换器的配置、密钥管理、以及资源服务如何本地验签这些环节,按我实际踩过的顺序讲清楚。尤其是那些官方文档一笔带过、但实际配置时必踩的坑,我会重点展开。读完你应该能独立完成一套可用的自定义 JWT 令牌方案,并且知道每个配置项背后的取舍逻辑。
2. 为什么默认令牌格式必须换掉:一次真实的性能对比
2.1 默认 UUID 令牌的存储依赖链路
先说说默认令牌到底是怎么工作的。Spring Security OAuth2 默认使用DefaultTokenServices配合一个TokenStore来管理令牌。当你请求/oauth/token拿到令牌时,服务端做的事情是:生成一个随机 UUID 作为令牌值,把OAuth2Authentication对象(包含用户信息和授权信息)序列化后存进TokenStore,然后把 UUID 返回给客户端。
资源服务收到请求后,带着这个 UUID 去问TokenStore:这个令牌有效吗?对应的用户是谁?TokenStore可能是InMemoryTokenStore、RedisTokenStore或者JdbcTokenStore。不管哪种,都意味着一次网络往返或者一次数据库查询。
我做过一个粗略的压测对比,在同样的硬件条件下,用 Redis 作为令牌存储,单机 QPS 大概在 3000 到 5000 之间就开始出现明显的响应时间抖动,瓶颈就卡在每次请求都要访问 Redis 校验令牌。而换成 JWT 之后,资源服务本地验签,同样的机器 QPS 能稳定在 8000 以上,响应时间的 P99 从 80ms 降到了 15ms 左右。这个差距在服务实例横向扩展之后会更明显,因为 JWT 方案下每个实例都是独立验签,不存在共享存储的竞争。
2.2 JWT 把校验成本从"查存储"变成"算签名"
JWT 的结构是三段式:头部(Header)、载荷(Payload)、签名(Signature),用点号连接。头部声明签名算法,载荷放实际数据,签名是对前两段的加密结果。资源服务拿到 JWT 后,用事先约定好的密钥重新计算一次签名,和令牌里的签名比对,一致就说明令牌没被篡改,直接信任载荷里的内容。
这个过程的成本是一次本地哈希运算,跟网络 IO 完全不在一个量级。代价是令牌本身变长了,默认 UUID 大概 36 个字符,JWT 动辄两三百个字符,但这点带宽开销相比省掉的存储查询,完全不值一提。
注意:JWT 的载荷只是 Base64 编码,不是加密。任何人拿到令牌都能解开看到里面的内容。所以绝对不要在载荷里放密码、身份证号这类敏感信息。需要保密的话,得额外做一层加密,那是另一个话题了。
2.3 自定义格式带来的业务灵活性
除了性能,自定义 JWT 最大的价值是能往载荷里塞业务字段。比如多租户系统里,把tenantId放进令牌,资源服务不用查库就知道当前请求属于哪个租户;再比如把用户角色列表authorities直接编码进去,网关层就能做粗粒度的权限拦截,不用每个请求都穿透到用户服务。
这些字段在默认 UUID 令牌下是做不到的,因为令牌本身只是个索引,所有信息都得回源查。而 JWT 让令牌从"索引"变成了"自包含的数据载体",这是整个改造的核心动机。
3. 签名算法怎么选:对称加密与非对称加密的取舍
3.1 HMAC 对称签名的适用边界
JWT 支持多种签名算法,最常用的是 HMAC 系列(HS256、HS384、HS512)和 RSA 系列(RS256、RS384、RS512)。HMAC 是对称加密,签名和验签用同一个密钥。配置简单,性能也好,适合授权服务和资源服务由同一个团队维护、能安全共享密钥的场景。
用 HMAC 的时候,密钥就是一串字符串,授权服务用它签名,资源服务用它验签。问题在于,密钥一旦泄露,任何人都能伪造令牌。而且密钥要在所有资源服务之间分发,分发渠道本身就成了安全短板。如果资源服务是第三方合作方提供的,把签名密钥给它,等于把伪造令牌的能力也给了它,这显然不合适。
3.2 RSA 非对称签名为什么更适合微服务
RSA 是非对称加密,授权服务持有私钥用于签名,资源服务只需要公钥就能验签。公钥可以随便公开,泄露了也没关系,因为它只能验签不能签名。这就解决了密钥分发的问题:授权服务把公钥暴露成一个接口(比如/oauth/token_key),资源服务启动时拉取一次缓存起来,后续本地验签。
代价是 RSA 的签名和验签比 HMAC 慢,密钥对生成也麻烦一些。但在微服务场景下,这个性能差距通常可以接受,换来的是更好的安全边界。我的建议是:内部服务之间、团队可控的情况下用 HMAC 图省事;涉及外部合作方、或者对安全要求高的场景,老老实实上 RSA。
| 对比维度 | HMAC(HS256) | RSA(RS256) |
|---|---|---|
| 密钥类型 | 对称,签名验签同一密钥 | 非对称,私钥签名公钥验签 |
| 密钥分发 | 需安全渠道分发到所有资源服务 | 公钥可公开,私钥仅授权服务持有 |
| 签名性能 | 快 | 相对慢 |
| 安全边界 | 密钥泄露即可伪造令牌 | 公钥泄露无影响 |
| 适用场景 | 内部服务、团队可控 | 外部合作、高安全要求 |
3.3 密钥长度与生成方式
不管选哪种算法,密钥强度都不能马虎。HMAC 的密钥建议至少 256 位,也就是 32 个字节的随机字符串,别用"secret""123456"这种。RSA 建议至少 2048 位,1024 位在现代算力下已经不安全了。
生成 RSA 密钥对可以用 JDK 自带的keytool,也可以用 OpenSSL。我习惯用keytool,因为生成的 JKS 文件能直接被 Spring 的KeyStoreKeyFactory读取,省去格式转换的麻烦。命令大概是这样:
keytool -genkeypair -alias my-jwt-key -keyalg RSA -keysize 2048 \ -keystore jwt.jks -validity 3650 -storepass changeit -keypass changeit \ -dname "CN=JWT, OU=Auth, O=Demo, L=City, ST=State, C=CN"生成出来的jwt.jks放到授权服务的资源目录下,配置里指定路径和密码就行。这里有个细节:-validity 3650是十年有效期,生产环境建议短一些并做好轮换计划,但开发阶段图省事可以设长点。
4. 授权服务改造:TokenEnhancer 与 JwtAccessTokenConverter 的配合
4.1 整体改造思路
授权服务这边的改造分三步走:第一,配置 JWT 转换器,告诉框架用 JWT 格式生成令牌;第二,写一个令牌增强器,往 JWT 载荷里塞自定义字段;第三,把转换器和增强器组装进TokenServices,替换掉默认的令牌服务。
这三步的顺序不能乱,因为增强器是在转换器生成 JWT 之前介入的,它往OAuth2Authentication的附加信息里写数据,转换器再把这些数据编码进 JWT。如果顺序搞反,增强器写的字段根本不会出现在最终令牌里。
4.2 JwtAccessTokenConverter 的配置要点
JwtAccessTokenConverter是框架提供的 JWT 转换器,负责把OAuth2Authentication对象转成 JWT 字符串,以及反向解析。配置它主要是设置签名密钥。
用 HMAC 的话,直接setSigningKey("你的密钥字符串")就行。用 RSA 的话,需要构造一个KeyPair传进去:
@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "changeit".toCharArray()); converter.setKeyPair(keyStoreKeyFactory.getKeyPair("my-jwt-key")); return converter; }这里有个容易踩的坑:KeyStoreKeyFactory的第二个参数是 keystore 的密码,getKeyPair的参数是别名。如果生成密钥时-keypass和-storepass设的不一样,这里会报错,因为KeyStoreKeyFactory默认用同一个密码去取私钥。所以生成时最好把两个密码设成一样,省得折腾。
4.3 自定义 TokenEnhancer 往载荷里加字段
默认的 JWT 载荷里只有user_name、scope、exp、authorities这些标准字段。要加自定义字段,得实现TokenEnhancer接口:
public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> additionalInfo = new HashMap<>(); additionalInfo.put("tenant_id", resolveTenantId(authentication)); additionalInfo.put("login_device", resolveDevice(authentication)); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }关键点在于setAdditionalInformation这个方法。它设置的信息会被JwtAccessTokenConverter读取并合并进 JWT 载荷。注意accessToken必须强转成DefaultOAuth2AccessToken,因为接口本身没有这个方法。
resolveTenantId和resolveDevice这些方法怎么实现,取决于你的业务。通常可以从authentication.getOAuth2Request().getRequestParameters()里取请求参数,或者从authentication.getPrincipal()里取用户对象。我一般会在用户登录时把这些信息塞进UserDetails的自定义实现里,增强器直接从 principal 里读,这样最干净。
4.4 组装 TokenServices 的完整配置
把转换器和增强器串起来,需要配置DefaultTokenServices:
@Bean public AuthorizationServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setTokenStore(tokenStore()); services.setSupportRefreshToken(true); services.setTokenEnhancer(tokenEnhancerChain()); services.setAccessTokenValiditySeconds(7200); services.setRefreshTokenValiditySeconds(604800); return services; } @Bean public TokenEnhancerChain tokenEnhancerChain() { TokenEnhancerChain chain = new TokenEnhancerChain(); List<TokenEnhancer> enhancers = new ArrayList<>(); enhancers.add(new CustomTokenEnhancer()); enhancers.add(jwtAccessTokenConverter()); chain.setTokenEnhancers(enhancers); return chain; }这里TokenEnhancerChain的顺序至关重要:自定义增强器必须排在jwtAccessTokenConverter前面。因为链式执行是从前往后,自定义增强器先把字段写进additionalInformation,转换器再把它编码成 JWT。如果顺序反了,转换器先生成 JWT,自定义增强器再改就没意义了。
setAccessTokenValiditySeconds(7200)是访问令牌两小时过期,setRefreshTokenValiditySeconds(604800)是刷新令牌七天。这两个值要根据业务安全等级调整,金融类应用可能只给十几分钟,内部管理系统给几小时也无妨。
5. 资源服务验签:本地校验 JWT 的完整链路
5.1 资源服务需要的最小配置
资源服务这边不需要TokenEnhancer,只需要能验签并解析 JWT。核心是配置一个JwtAccessTokenConverter,用和授权服务相同的密钥(HMAC)或者公钥(RSA),然后把它塞进ResourceServerConfigurerAdapter。
用 RSA 的话,资源服务不持有私钥,需要从授权服务的/oauth/token_key接口拉公钥。Spring Security OAuth2 提供了JwtAccessTokenConverter的setVerifierKey方法,可以直接设置公钥字符串。更省事的做法是用RemoteTokenServices的替代方案——配置一个@Bean让框架自动从授权服务拉取:
@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(publicKey); // 从授权服务获取的公钥 return converter; }公钥的获取方式有两种:启动时通过 HTTP 拉取一次缓存,或者直接硬编码在配置文件里。硬编码的好处是启动不依赖授权服务,坏处是密钥轮换时要改配置重启。我一般用配置中心管理公钥,轮换时推一次配置就行。
5.2 资源服务如何读取 JWT 里的自定义字段
JWT 验签通过后,载荷里的字段会被放进OAuth2Authentication的additionalInformation里。资源服务要读这些字段,可以从SecurityContextHolder拿:
OAuth2Authentication authentication = (OAuth2Authentication) SecurityContextHolder.getContext().getAuthentication(); Map<String, Object> details = (Map<String, Object>) authentication.getOAuth2Request().getExtensions().get("additionalInformation"); String tenantId = (String) details.get("tenant_id");这里有个版本差异要注意:SpringBoot 2.0 对应的 Spring Security OAuth2 版本里,自定义字段的存放位置和后续版本不太一样。有的版本放在authentication.getDetails()里,有的放在getOAuth2Request().getExtensions()里。实际调试时建议先把整个authentication对象打印出来,看清楚字段到底在哪一层,别照着网上的文章硬套。
5.3 验签失败的常见原因排查
JWT 验签失败报错通常很笼统,就一句"Invalid token",具体原因得自己排查。我整理了几种最常见的情况:
| 报错现象 | 可能原因 | 排查方法 |
|---|---|---|
| 签名验证失败 | 授权服务和资源服务密钥不一致 | 对比两边配置的密钥或公钥 |
| 令牌已过期 | 时钟不同步或有效期设太短 | 检查服务器时间,核对 exp 字段 |
| 载荷解析异常 | 自定义字段类型不被支持 | 确认字段是基本类型或可序列化 |
| 找不到公钥 | 资源服务未正确加载公钥 | 检查公钥配置和加载日志 |
时钟同步这个问题特别隐蔽。JWT 的exp和iat都是基于服务器时间的,如果授权服务和资源服务的系统时间差了几分钟,就可能出现刚签发的令牌在资源服务这边已经过期的情况。生产环境务必保证所有节点走同一个时间源。
6. 那些文档不会告诉你的坑:我实际踩过的五个问题
6.1 刷新令牌时自定义字段丢失
第一个坑出现在刷新令牌环节。访问令牌过期后用刷新令牌换新令牌,新令牌里的自定义字段全没了。原因是刷新流程走的是RefreshTokenGranter,它重新构造OAuth2Authentication时不会带上原来的additionalInformation。
解决办法是在自定义增强器里做兼容:刷新场景下从刷新令牌关联的认证信息里恢复字段。具体做法是重写enhance方法时判断authentication.getOAuth2Request().getRefreshToken()是否存在,存在就从原认证信息里捞数据。这个逻辑有点绕,但必须处理,否则用户刷新一次令牌就丢了租户信息,后续请求全乱套。
6.2 自定义字段类型导致的序列化异常
第二个坑是往 JWT 里塞了复杂对象。JWT 载荷最终要序列化成 JSON,如果你塞了一个自定义的 POJO,而它没有无参构造函数或者有循环引用,序列化就会失败,报错信息还特别难懂。
我的经验是:JWT 载荷里只放字符串、数字、布尔值这些基本类型,最多放List<String>这种简单集合。复杂结构拆成多个扁平字段,或者干脆只放一个 ID,让资源服务拿着 ID 去查。JWT 不是数据库,别把它当万能容器用。
6.3 令牌长度超出请求头限制
第三个坑跟令牌长度有关。JWT 本身比 UUID 长,如果权限列表特别多,authorities字段能把令牌撑到好几 KB。有些网关或反向代理对请求头大小有限制,默认可能只有 8KB,令牌一长就被截断,导致验签失败。
排查这种问题很痛苦,因为报错和密钥错误长得一样。我的做法是:在授权服务生成令牌后打印一下长度,超过 2KB 就要警惕。如果确实需要携带大量权限,考虑只放角色编码不放具体权限,或者把权限数据放到独立的缓存服务里,JWT 里只放一个引用键。
6.4 密钥轮换时的平滑过渡
第四个坑是密钥轮换。业务发展过程中密钥总得换,但直接换会导致所有已签发的令牌立即失效,用户全部被踢下线。平滑过渡的做法是让资源服务同时信任新旧两个密钥,验签时先试新密钥,失败再试旧密钥,等旧令牌自然过期后再移除旧密钥。
Spring Security OAuth2 原生不支持多密钥验签,需要自己扩展JwtAccessTokenConverter,重写decode方法实现多密钥尝试。这块代码量不大但逻辑要仔细,我建议在测试环境充分验证后再上生产。
6.5 开发环境与生产环境的配置差异
第五个坑是环境差异。开发时图方便用 HMAC 加个简单密钥,生产要换 RSA,结果配置项对不上,启动就报错。我的建议是从一开始就统一用 RSA,开发环境生成一套测试密钥对,生产环境用另一套,配置结构完全一致,只是密钥文件不同。这样迁移时只换文件不改代码,风险最小。
7. 性能与安全的平衡:几个值得权衡的配置项
7.1 令牌有效期设多长合适
访问令牌有效期是个典型的权衡:设短了安全,但用户频繁刷新体验差;设长了方便,但令牌泄露后的风险窗口大。我的经验值是:面向公众的应用设 30 分钟到 1 小时,内部管理系统设 2 到 4 小时,配合刷新令牌使用。刷新令牌的有效期可以设长一些,但要做好吊销机制,用户登出或改密码时立即失效。
7.2 是否把权限列表放进 JWT
把authorities放进 JWT 的好处是资源服务不用查库就能做权限判断,坏处是权限变更后旧令牌里的权限还是旧的,得等令牌过期才生效。如果权限变更需要实时生效,就不能把权限放 JWT,得让资源服务每次去权限服务查。
折中方案是放角色编码不放细粒度权限,角色变更频率低,实时性要求也没那么高。细粒度权限在资源服务本地缓存,用角色编码做键,这样既减少了查询又保证了灵活性。
7.3 签名算法与密钥强度的匹配
最后提醒一点:算法和密钥强度要匹配。用 HS256 却配了个 8 位密钥,等于没设防,暴力破解分分钟的事。用 RS256 却只生成 1024 位密钥,同样不安全。密钥强度至少要和算法要求对齐,HMAC 至少 256 位,RSA 至少 2048 位,这是底线。
我在实际项目里还遇到过一种情况:团队为了"性能"把 RSA 换成 HMAC,结果密钥在多个服务间明文传输,安全审计直接亮了红灯。性能优化要在安全底线之上做,不能本末倒置。真觉得 RSA 慢,可以考虑用椭圆曲线算法(ES256),密钥更短、性能更好,安全性也够,只是 Spring Security OAuth2 对它的支持需要额外配置,稍微麻烦一点。
整套改造做下来,最深的体会是:JWT 不是银弹,它解决的是令牌校验的性能和自包含问题,但引入了密钥管理、令牌吊销、字段序列化这些新问题。用之前想清楚业务到底需不需要自包含令牌,如果只是几个内部服务、并发也不高,默认的 UUID 令牌配合 Redis 存储其实更简单可靠。技术选型永远是为业务服务的,别为了用而用。