☰
SpringCloud OAuth2自定义JWT令牌:从默认令牌到无状态鉴权实战
2026/10/15 17:37:21 网站建设 项目流程

1. 从默认令牌到自定义JWT:为什么必须走这一步

做过微服务认证授权的朋友大概率都经历过这样一个阶段:项目初期用Spring Security OAuth2的默认配置快速跑通了授权码模式,令牌也能正常发放和校验,一切看起来都很美好。但等到前端联调、网关鉴权、多服务共享用户信息的时候,问题就一个接一个冒出来了。默认的令牌是一串无意义的UUID字符串,每次校验都要回授权服务器查一次,服务一多,授权服务器就成了瓶颈;更麻烦的是,网关想从令牌里直接拿到用户ID和角色信息做路由级别的权限判断,却发现默认令牌里什么都没有,只能再发一次请求去问授权服务器。

这就是为什么我们需要把默认的透明令牌换成JWT(JSON Web Token)。JWT的本质是一种自包含的令牌格式,它把用户身份信息、权限范围、过期时间等数据直接编码进令牌本身,服务端拿到令牌后只需要用密钥验签就能确认其合法性,不需要每次都回源查询。这个特性在微服务架构下尤其重要,因为它让无状态鉴权成为可能,网关和各个微服务可以独立完成令牌校验,大大降低了授权中心的压力。

这篇文章要聊的,就是在一个已经整合了SpringCloud和SpringBoot 2.0的OAuth2体系中,如何把默认的令牌格式替换成自定义的JWT格式。我会从整体设计思路讲起,把令牌结构设计、签名密钥管理、自定义字段扩展、网关解析、资源服务器验签这些环节全部拆开揉碎,配上可以直接参考的代码和配置。不管你是刚开始接触OAuth2的新手,还是已经跑通过默认流程想进一步定制的开发者,应该都能从里面找到有用的东西。

需要提前说明的是,这篇文章假设你已经完成了SpringCloud与SpringBoot 2.0的基础整合,OAuth2的授权码模式和密码模式至少跑通了一种。如果你还没到这一步,建议先把基础流程走通再来看JWT定制,否则容易在细节里迷路。

2. 整体设计思路与方案选型

2.1 为什么不用默认令牌而选择JWT

Spring Security OAuth2默认生成的令牌是一串随机字符串,服务端会把它和对应的认证信息一起存到TokenStore里。这种设计的好处是令牌本身不携带任何信息,泄露了也无法直接解读出用户数据;但坏处也很明显——每次校验都要查存储,而且跨服务共享时每个服务都得能访问同一个TokenStore。

在微服务场景下,我们通常希望网关层就能完成鉴权,而不是把请求转发到具体服务后再去查授权中心。JWT正好满足这个需求:令牌自带用户信息,网关用公钥或共享密钥验签后就能直接解析出用户身份和权限,判断该请求是否放行。这样一来,授权中心只需要负责发放令牌,校验环节完全去中心化。

还有一个实际考量是前端的需求。前端经常需要从令牌里读取用户昵称、头像、角色列表等信息来渲染界面。默认令牌是一串乱码,前端只能再调一次用户信息接口;JWT的payload是Base64编码的JSON,前端可以直接解码使用,省去一次网络请求。

2.2 JWT的三段式结构与签名机制

JWT由三部分组成,用点号分隔:Header、Payload、Signature。Header描述令牌类型和签名算法,Payload存放实际数据(也叫Claims),Signature是对前两部分的签名,用来防篡改。

签名机制是JWT安全性的核心。以HS256为例,签名过程是:把Base64Url编码后的Header和Payload用点号拼接,然后用密钥做HMAC-SHA256运算,结果再Base64Url编码就得到Signature。服务端校验时用同样的密钥和算法重新计算签名,和令牌里的Signature比对,一致就说明令牌没被篡改。

这里有个关键点:Payload只是Base64编码,不是加密。任何人拿到JWT都能解码看到里面的内容。所以绝对不能在Payload里放密码、身份证号这类敏感信息。我们放的是用户ID、用户名、角色列表这些可以公开但需要防篡改的数据。

2.3 对称加密与非对称加密的取舍

JWT签名可以用对称密钥(HS256/HS384/HS512),也可以用非对称密钥(RS256/RS384/RS512)。对称加密的优点是运算快、配置简单,授权中心和资源服务器共享同一个密钥就行;缺点是密钥一旦泄露,所有服务都受影响,而且任何持有密钥的服务都能签发令牌,权限边界不清晰。

非对称加密用私钥签名、公钥验签。授权中心持有私钥负责签发,资源服务器只持有公钥负责校验,即使公钥泄露也无法伪造令牌。在服务数量多、安全要求高的场景下,RS256是更稳妥的选择。

我的建议是:如果只是内部几个服务,网络环境可控,HS256足够用且性能更好;如果服务数量多、有对外暴露的网关、或者对安全性有更高要求,直接上RS256,后期不用再折腾迁移。

2.4 自定义TokenServices的核心思路

Spring Security OAuth2默认用DefaultTokenServices来管理令牌,它依赖TokenStore来存储和读取。要换成JWT,核心就是替换TokenStore的实现,改用JwtTokenStore,同时配置JwtAccessTokenConverter来处理JWT的编解码和签名。

JwtAccessTokenConverter负责两件事:生成令牌时把OAuth2的认证信息转换成JWT的Claims并签名;校验令牌时验签并把Claims还原成OAuth2的认证信息。我们可以通过继承它并重写enhance方法,往JWT里塞入自定义字段,比如用户ID、租户标识、扩展权限等。

整个链路是这样的:客户端请求令牌 -> 授权中心认证用户 -> TokenServices调用JwtAccessTokenConverter生成JWT -> 返回给客户端 -> 客户端携带JWT访问资源 -> 网关或资源服务器的JwtTokenStore调用JwtAccessTokenConverter验签并解析 -> 得到认证信息完成鉴权。

3. 核心细节解析与实操要点

3.1 依赖引入与版本对齐

SpringBoot 2.0对应的Spring Security OAuth2版本是2.3.x左右,这个版本对JWT的支持已经比较完善。需要引入的依赖主要是两个:spring-security-jwt和spring-security-oauth2。前者提供JWT编解码能力,后者提供OAuth2的授权流程。

<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-jwt</artifactId> <version>1.0.9.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.3.RELEASE</version> </dependency>

版本对齐很重要。spring-security-jwt的1.0.x系列和SpringBoot 2.0是兼容的,如果用1.1.x可能会遇到API变化导致的编译错误。我踩过一次坑,升级了jwt版本后JwtAccessTokenConverter的构造函数签名变了,排查了半天才发现是版本问题。

注意:如果你的项目用了SpringCloud的starter,可能会间接引入不同版本的oauth2依赖,建议用mvn dependency:tree检查一下实际生效的版本,避免冲突。

3.2 JwtAccessTokenConverter的配置与自定义

JwtAccessTokenConverter是JWT处理的核心类。基础配置只需要设置签名密钥:

@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("your-secret-key-at-least-32-bytes"); return converter; }

但实际项目中我们往往需要往JWT里加自定义字段。比如用户所属的租户ID、用户昵称、扩展权限列表等。这时候就需要继承JwtAccessTokenConverter并重写enhance方法:

public class CustomJwtAccessTokenConverter extends JwtAccessTokenConverter { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> additionalInfo = new HashMap<>(); Object principal = authentication.getPrincipal(); if (principal instanceof CustomUserDetails) { CustomUserDetails user = (CustomUserDetails) principal; additionalInfo.put("userId", user.getUserId()); additionalInfo.put("tenantId", user.getTenantId()); additionalInfo.put("nickname", user.getNickname()); } ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return super.enhance(accessToken, authentication); } }

这里有个细节:additionalInformation里的内容会被JwtAccessTokenConverter自动合并到JWT的Claims中。但要注意,如果key和OAuth2标准字段(如exp、user_name、authorities)重名,会覆盖标准字段,可能导致校验异常。建议自定义字段加个前缀,比如"ext_userId",避免冲突。

3.3 TokenStore与TokenServices的替换

默认的TokenStore是InMemoryTokenStore或RedisTokenStore,换成JWT后要用JwtTokenStore:

@Bean public TokenStore tokenStore(JwtAccessTokenConverter converter) { return new JwtTokenStore(converter); } @Bean public DefaultTokenServices tokenServices(TokenStore tokenStore) { DefaultTokenServices services = new DefaultTokenServices(); services.setTokenStore(tokenStore); services.setSupportRefreshToken(true); services.setAccessTokenValiditySeconds(7200); services.setRefreshTokenValiditySeconds(604800); return services; }

JwtTokenStore的特点是它不存储令牌,只负责用converter做编解码。这意味着令牌一旦签发就无法主动撤销,只能等它自然过期。如果你的业务需要强制下线功能,得额外维护一个黑名单,这个后面会讲。

3.4 授权服务器端点配置

授权服务器的配置类需要继承AuthorizationServerConfigurerAdapter,把上面定义的TokenStore和TokenServices装配进去:

@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private TokenStore tokenStore; @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Autowired private AuthenticationManager authenticationManager; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("web-client") .secret("{noop}client-secret") .authorizedGrantTypes("password", "refresh_token") .scopes("read", "write") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(604800); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.tokenStore(tokenStore) .accessTokenConverter(jwtAccessTokenConverter) .authenticationManager(authenticationManager); } @Override public void configure(AuthorizationServerSecurityConfigurer security) { security.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } }

注意tokenKeyAccess的配置。如果资源服务器需要用公钥验签(RS256场景),需要开放/oauth/token_key端点让资源服务器获取公钥。用HS256的话这个端点可以关掉。

4. 实操过程与核心环节实现

4.1 自定义UserDetails的设计

JWT里要放什么字段,取决于UserDetails里有什么。我们需要自定义一个UserDetails实现,把业务需要的用户信息装进去:

public class CustomUserDetails implements UserDetails { private Long userId; private String username; private String password; private String tenantId; private String nickname; private Collection<? extends GrantedAuthority> authorities; // 省略getter/setter和接口方法实现 }

对应的UserDetailsService从数据库加载用户时,把这些字段一并查出来填充进去。这样在enhance方法里就能从authentication.getPrincipal()拿到这些数据。

实操心得:UserDetails里的password字段在生成JWT时会被自动过滤掉,不会出现在Claims里,这个可以放心。但如果你自定义了其他敏感字段,记得在enhance方法里不要往additionalInfo里放。

4.2 生成JWT并验证内容

配置完成后,用密码模式请求令牌:

curl -X POST http://localhost:8080/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password" \ -d "username=testuser" \ -d "password=testpass" \ -d "client_id=web-client" \ -d "client_secret=client-secret" \ -d "scope=read write"

返回的access_token就是JWT格式,形如"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx.yyyyy"。把它复制到jwt.io或者用代码解码,可以看到Payload里包含:

{ "user_name": "testuser", "scope": ["read", "write"], "exp": 1699999999, "authorities": ["ROLE_USER"], "jti": "a1b2c3d4-...", "client_id": "web-client", "userId": 1001, "tenantId": "T001", "nickname": "测试用户" }

userId、tenantId、nickname就是我们通过enhance方法塞进去的自定义字段。网关拿到这个令牌后,验签通过就能直接读取这些信息,不需要再调用户服务。

4.3 资源服务器的JWT验签配置

资源服务器需要配置JwtTokenStore和JwtAccessTokenConverter来校验令牌。如果用HS256,资源服务器和授权服务器共享同一个签名密钥:

@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("your-secret-key-at-least-32-bytes"); return converter; } @Bean public TokenStore tokenStore(JwtAccessTokenConverter converter) { return new JwtTokenStore(converter); } @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.tokenStore(tokenStore(jwtAccessTokenConverter())); } @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/api/public/**").permitAll() .anyRequest().authenticated(); } }

如果用RS256,资源服务器配置的是公钥:

@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(publicKeyString); return converter; }

公钥可以从授权服务器的/oauth/token_key端点获取,也可以直接配置在资源服务器的配置文件里。生产环境建议配置在配置中心,方便统一管理。

4.4 网关层的JWT解析与用户信息透传

在SpringCloud Gateway或Zuul网关层,我们通常需要解析JWT并把用户信息透传给下游服务。以Gateway为例,可以写一个GlobalFilter:

@Component public class JwtAuthFilter implements GlobalFilter, Ordered { private final JwtAccessTokenConverter converter; public JwtAuthFilter(JwtAccessTokenConverter converter) { this.converter = converter; } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = extractToken(exchange.getRequest()); if (token != null) { try { OAuth2Authentication auth = converter.extractAuthentication( converter.decode(token)); Map<String, Object> details = (Map<String, Object>) auth.getUserAuthentication().getDetails(); ServerHttpRequest request = exchange.getRequest().mutate() .header("X-User-Id", String.valueOf(details.get("userId"))) .header("X-Tenant-Id", String.valueOf(details.get("tenantId"))) .build(); return chain.filter(exchange.mutate().request(request).build()); } catch (Exception e) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } } return chain.filter(exchange); } @Override public int getOrder() { return -100; } }

这样下游服务只需要从请求头里读X-User-Id和X-Tenant-Id,不需要再解析JWT,代码更简洁。但要注意,下游服务不能无条件信任这两个头,如果服务直接暴露在外网,攻击者可以伪造请求头。所以要么下游服务也做JWT校验,要么网关层确保外部请求的这两个头被剥离。

4.5 刷新令牌与JWT的配合

JWT默认不支持刷新,因为刷新令牌需要服务端存储状态。但Spring Security OAuth2的DefaultTokenServices配合JwtTokenStore时,刷新令牌仍然可以工作——刷新令牌本身也是JWT格式,里面包含了过期时间和用户信息,服务端验签后重新生成一个access_token。

刷新请求:

curl -X POST http://localhost:8080/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "refresh_token=your-refresh-token" \ -d "client_id=web-client" \ -d "client_secret=client-secret"

这里有个坑:如果刷新令牌也过期了,客户端需要重新走完整的授权流程。所以refreshTokenValiditySeconds要设置得比accessTokenValiditySeconds长很多,通常access_token设2小时,refresh_token设7天。

5. 常见问题与排查技巧实录

5.1 令牌解析失败:签名不匹配

这是最常见的问题,报错通常是"JWT signature does not match locally computed signature"。原因无非三种:密钥不一致、算法不一致、令牌被篡改。

排查步骤:先确认授权服务器和资源服务器的signingKey完全一致,包括大小写和空格。然后检查JWT Header里的alg字段,如果是HS256但资源服务器配置的是RS256,肯定验签失败。最后用jwt.io解码看看Payload是否完整,如果Payload被截断也会导致验签失败。

避坑技巧:密钥长度有要求。HS256要求密钥至少256位(32字节),如果密钥太短,某些版本的jjwt库会直接抛异常。建议用UUID或者随机字符串生成器生成足够长的密钥。

5.2 自定义字段丢失

有时候发现JWT里没有自定义字段,或者资源服务器解析后拿不到。原因可能是enhance方法没有被调用,或者additionalInformation被覆盖了。

检查点:确认AuthorizationServerEndpointsConfigurer里配置了accessTokenConverter,否则TokenServices不会调用自定义的converter。另外,如果用了自定义的TokenEnhancer链,注意执行顺序,后面的enhancer可能覆盖前面的结果。

5.3 令牌过期时间不对

JWT的exp字段是Unix时间戳,单位是秒。如果发现令牌很快就过期,检查accessTokenValiditySeconds的单位——它是秒不是毫秒。另外,如果客户端和服务器时间不同步,也可能导致令牌被判定为已过期。生产环境建议所有服务器开启NTP时间同步。

5.4 网关解析JWT报错

网关层解析JWT时常见的错误是"ClassCastException",通常是因为authentication.getDetails()返回的类型和预期不符。JwtAccessTokenConverter解析后的details是一个Map,但如果你在授权服务器端往details里放了自定义对象,序列化后反序列化回来可能变成LinkedHashMap。建议统一用Map来传递数据,避免类型转换问题。

5.5 常见问题速查表

问题现象可能原因排查方向
签名不匹配密钥不一致/算法不一致对比两端配置,检查alg字段
自定义字段丢失converter未生效/enhancer覆盖检查endpoints配置和enhancer顺序
令牌提前过期时间不同步/单位错误检查NTP和validitySeconds单位
网关解析异常details类型不符统一用Map传递,避免自定义对象
刷新令牌失败refresh_token过期/被撤销检查refreshTokenValiditySeconds
令牌无法撤销JwtTokenStore无状态引入黑名单机制

5.6 令牌撤销的黑名单方案

JWT最大的短板是无法主动撤销。如果用户改了密码或者管理员强制下线,已经签发的JWT在过期前仍然有效。解决方案是维护一个黑名单,网关校验时先查黑名单。

简单实现:用Redis存储被撤销的jti(JWT ID),设置过期时间和令牌剩余有效期一致。网关解析JWT后拿jti去Redis查,存在就拒绝。这样既保留了JWT的无状态优势,又补上了撤销能力。

// 撤销令牌时 String jti = (String) claims.get("jti"); long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); redisTemplate.opsForValue().set("jwt:blacklist:" + jti, "1", ttl, TimeUnit.MILLISECONDS);

实操心得:黑名单的key建议加前缀,避免和业务缓存冲突。另外,如果Redis挂了,要有降级策略——要么放行(可用性优先),要么拒绝(安全性优先),根据业务场景选择。

6. 性能优化与安全加固建议

6.1 JWT体积控制

JWT是每次请求都要携带的,体积太大会增加网络开销。Payload里不要放太多数据,角色列表如果很长,可以考虑只放角色ID,具体权限由资源服务器查缓存获取。另外,Header里的typ和alg是固定值,无法精简,但Payload里的自定义字段要克制。

我见过一个项目把用户的完整权限树塞进JWT,令牌长度超过8KB,每次请求头都巨大,网关日志都被撑爆了。后来改成只放角色编码,权限树由资源服务器从缓存加载,令牌长度降到1KB以内。

6.2 签名算法的选择

HS256性能最好,适合内部服务;RS256安全性更高,适合对外场景。如果用了RS256,注意密钥长度至少2048位,1024位的RSA密钥已经不被推荐。另外,签名和验签是CPU密集型操作,高并发场景下可以考虑用ES256(椭圆曲线签名),性能比RS256好,密钥也更短。

6.3 防止令牌泄露的额外措施

JWT一旦泄露,攻击者就能在过期前一直使用。除了HTTPS传输、短有效期这些基本措施,还可以绑定客户端指纹——在JWT里放一个客户端生成的随机值,请求时同时携带这个值,服务端比对一致才放行。这样即使令牌被截获,攻击者没有客户端指纹也无法使用。

另一个措施是限制令牌的使用范围。比如给每个微服务分配不同的audience,JWT里包含aud字段,资源服务器校验aud是否匹配,防止令牌被跨服务滥用。

6.4 密钥轮换策略

长期使用同一个签名密钥存在风险。建议定期轮换密钥,但轮换时要注意平滑过渡——新令牌用新密钥签,旧令牌用旧密钥验,等旧令牌全部过期后再下线旧密钥。实现方式是在JwtAccessTokenConverter里配置多个VerifierKey,验签时依次尝试。

这个功能在spring-security-jwt里没有直接支持,需要自己扩展。一个简单的做法是用一个Map存储密钥ID和密钥的对应关系,JWT Header里带上kid(Key ID),验签时根据kid选择对应的密钥。

7. 实际项目中的经验体会

整套JWT方案我在几个项目里落地过,踩过的坑基本都写在上面了。最后分享几个文档里不会写但实际很重要的点。

第一,JWT的Payload是Base64编码不是加密,这个一定要跟团队里所有人都讲清楚。我遇到过前端同学把用户手机号放进JWT,觉得别人看不到,这是很危险的误解。

第二,网关透传用户信息时,一定要确保外部请求的伪造头被剥离。我们曾经出过一次事故,攻击者直接在请求头里加X-User-Id访问内部服务,因为那个服务没有做JWT校验,直接信任了请求头。后来在网关加了过滤器,把所有外部请求的X-User-Id、X-Tenant-Id等头全部清除,再由网关重新写入。

第三,JWT的过期时间不要设太长。有些团队为了减少刷新频率,把access_token有效期设成24小时,这等于给攻击者留了一整天的窗口。建议2小时以内,配合refresh_token自动续期,用户体验不会差太多。

第四,如果用了RS256,公钥的获取方式要设计好。直接从授权服务器的/oauth/token_key端点拉取虽然方便,但授权服务器重启或网络抖动时资源服务器可能拿不到公钥。建议在资源服务器本地缓存公钥,定期刷新,拿不到时用缓存兜底。

第五,测试环境一定要模拟令牌过期和签名错误的场景。很多问题在开发阶段发现不了,等到生产环境令牌过期了才报错,排查起来很被动。建议写单元测试覆盖这些边界情况,用MockMvc模拟请求,断言返回401而不是500。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询