资讯动态

SpringBoot 2.0 OAuth2 自定义 JWT 令牌改造实战

发布时间:2026/10/15 17:37:39 来源:尧图企业网站定制
1. 从默认令牌到自定义JWT这次改造要解决什么问题做过微服务认证的朋友大概都有过这种体验用 Spring Security OAuth2 搭好授权服务器之后默认发出来的 access_token 就是一串毫无规律的 UUID 字符串比如a3f8c2e1-9b4d-4f7a-8e2c-1d5f6a7b8c9d这种。这串东西本身不携带任何业务信息资源服务器拿到它之后必须再回头调用授权服务器的/oauth/check_token接口去校验和换取用户信息。请求量小的时候无所谓一旦并发上来授权服务器就成了整个链路的瓶颈而且每次校验都是一次额外的网络往返延迟叠加得很明显。这个项目要干的事情就是把默认那串“无意义”的令牌换成JWTJson Web Token。JWT 最大的特点是把用户身份、权限、过期时间这些信息直接编码进令牌本身并且用密钥签名防篡改。资源服务器拿到令牌后本地验签就能确认合法性不需要每次都去问授权服务器。这一改动带来的收益是实打实的减少一次远程调用、降低授权服务器压力、令牌自包含信息便于排查问题。不过这里有个前提得说清楚JWT 适合做无状态的认证场景一旦签发就无法主动作废除非引入黑名单机制。所以它更适合令牌有效期较短、对实时吊销要求不高的业务。如果你的系统需要“踢人下线”这种强控制就得额外设计一套令牌黑名单这是后话本文先把自定义 JWT 格式这条主线走通。适合阅读这篇内容的人已经用 SpringBoot 2.0 和 SpringCloud 搭过基础 OAuth2 认证流程能跑通密码模式拿到令牌现在想进一步把令牌格式换成 JWT 的开发者。如果你连授权服务器都还没搭起来建议先把前几步的基础流程走完再来看这篇不然会有点跳跃。2. 整体设计思路与方案选型拆解2.1 为什么选 JWT 而不是继续用默认令牌默认令牌也叫不透明令牌Opaque Token的机制是授权服务器生成一个随机字符串把它和用户信息的映射关系存到内存或数据库里资源服务器校验时必须回查。这套机制的好处是令牌状态完全可控想失效随时删记录就行。但坏处也很明显就是前面说的性能问题。JWT 走的是另一条路——自包含。一个标准的 JWT 由三部分组成用点号分隔Header头部声明签名算法比如{alg:HS256,typ:JWT}Payload载荷存放实际数据比如用户名、权限、过期时间Signature签名用密钥对前两部分签名防止被篡改这三部分各自做 Base64Url 编码后拼起来就是最终看到的令牌。资源服务器拿到后用同一把密钥重新计算签名对得上就说明没被改过直接信任 Payload 里的内容。注意JWT 的 Payload 只是 Base64 编码不是加密。任何人拿到令牌都能解出里面的内容。所以绝对不要把密码、身份证号这类敏感信息放进 Payload这是新手最容易踩的坑。2.2 签名算法怎么选对称还是非对称JWT 的签名算法分两大类选哪个直接决定了密钥怎么管理算法类型代表算法密钥特点适用场景对称加密HS256签名和验签用同一把密钥授权服务器和资源服务器互信、部署在同一体系内非对称加密RS256私钥签名、公钥验签资源服务器众多、不希望每台都持有签名密钥对称算法HS256配置简单一把密钥搞定适合内部系统。非对称算法RS256安全性更高因为验签方只拿到公钥即使泄露也无法伪造令牌适合资源服务器分散、或者有第三方接入的场景。这个项目里我选的是HS256 对称加密理由是授权服务器和资源服务器都在自己的微服务集群内密钥可以通过配置中心统一分发没必要引入非对称的复杂度。如果你的场景涉及外部系统对接建议换成 RS256把公钥暴露出去即可。2.3 改造的核心切入点在哪里Spring Security OAuth2 的令牌生成逻辑集中在TokenStore和TokenServices这两个组件上。默认情况下用的是DefaultTokenServices配合InMemoryTokenStore或JdbcTokenStore生成的是不透明令牌。要换成 JWT核心就是替换掉这两个组件用JwtTokenStore替换原来的 TokenStore用JwtAccessTokenConverter负责令牌的编码和解码用JwtTokenEnhancer自定义往令牌里塞额外的业务字段整个改造不需要动认证流程本身只是把“令牌长什么样、怎么存”这一层换掉。这也是 Spring Security OAuth2 设计得比较优雅的地方职责分离得清楚替换起来不会牵一发动全身。3. 核心配置与实操要点详解3.1 依赖引入与版本对齐SpringBoot 2.0 对应的 Spring Security OAuth2 版本是 2.3.x 系列这个版本对 JWT 的支持已经比较完善。需要在pom.xml里补上 JWT 相关的依赖dependency groupIdorg.springframework.security.oauth/groupId artifactIdspring-security-oauth2/artifactId version2.3.4.RELEASE/version /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-jwt/artifactId version1.0.9.RELEASE/version /dependency这里有个版本坑要提醒spring-security-jwt的版本不能随便升1.0.9 是跟 SpringBoot 2.0 搭配比较稳的版本。我试过升到 1.1.x结果JwtAccessTokenConverter的某些方法签名变了编译直接报错。所以版本对齐这件事宁可保守也不要激进。3.2 密钥配置别把密钥硬编码在代码里签名密钥是整个 JWT 安全性的根基一旦泄露别人就能伪造任意令牌。我见过不少示例代码直接把密钥写成字符串常量放在 Java 类里这在生产环境是大忌。正确的做法是把密钥放到配置文件通过Value注入oauth2: jwt: signing-key: my-super-secret-signing-key-please-change然后在配置类里注入使用。密钥长度也有讲究HS256 要求密钥至少 256 位32 字节太短的话某些 JWT 库会直接抛异常。我一般建议密钥字符串长度不低于 32 个字符并且包含大小写字母和数字。提示生产环境的密钥应该通过配置中心或环境变量注入不要提交到代码仓库。如果团队有密钥轮换需求还要考虑新旧密钥并存的过渡方案。3.3 JwtAccessTokenConverter 的配置细节JwtAccessTokenConverter是 JWT 改造的核心组件它负责把 OAuth2 的认证信息编码成 JWT以及把 JWT 解码回认证信息。配置起来就几行代码但有几个细节值得展开说Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(signingKey); // 自定义令牌增强器往 JWT 里塞额外字段 converter.setAccessTokenConverter(new CustomAccessTokenConverter()); return converter; }setSigningKey设置的就是签名密钥。setAccessTokenConverter这个方法容易被忽略它决定了 JWT 的 Payload 里到底放哪些字段。默认情况下Spring 会把user_name、scope、exp、authorities这些标准字段放进去。如果你想让令牌携带更多业务信息比如用户 ID、租户 ID、部门编码就需要自定义一个AccessTokenConverter。3.4 自定义 TokenEnhancer 往令牌里加料标准 JWT 的 Payload 字段有限实际业务里往往需要更多信息。这时候就要用到TokenEnhancer它能在令牌生成前对内容做增强。public class CustomTokenEnhancer implements TokenEnhancer { Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { MapString, 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()); } ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }这里的关键是setAdditionalInformation放进去的 Map 会被合并到 JWT 的 Payload 里。资源服务器解析令牌后就能直接读到userId、tenantId这些字段不用再查数据库。注意往 Payload 里塞的字段越多令牌体积越大。JWT 会随着每次请求的 Header 一起传输体积过大会增加网络开销。我一般建议 Payload 控制在 1KB 以内只放真正高频使用的字段。3.5 授权服务器与资源服务器的配置差异授权服务器需要签名密钥能签发也能验证资源服务器只需要验签密钥能验证即可。在对称加密场景下两者用的是同一把密钥但职责不同授权服务器的配置里要注册JwtTokenStore、JwtAccessTokenConverter、TokenEnhancer这一整套资源服务器则只需要配置JwtTokenStore和JwtAccessTokenConverter用于解析令牌。// 资源服务器侧 Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(signingKey); return converter; } Bean public TokenStore tokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); }资源服务器不需要 TokenEnhancer因为它不签发令牌只验证。这个区别在配置时容易搞混导致资源服务器报“找不到 TokenEnhancer”之类的错误。4. 完整实操流程与关键环节实现4.1 授权服务器改造的完整步骤先把授权服务器的改造流程走一遍。假设你已经有一个能跑通密码模式的授权服务器改造分四步第一步定义 JWT 配置类把密钥、转换器、令牌存储这几个 Bean 注册进去Configuration public class JwtTokenConfig { Value(${oauth2.jwt.signing-key}) private String signingKey; Bean public TokenStore jwtTokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); } Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(signingKey); return converter; } Bean public TokenEnhancer customTokenEnhancer() { return new CustomTokenEnhancer(); } }第二步在 AuthorizationServerConfig 里组装令牌服务。这里要注意 TokenEnhancer 的链式组合Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { TokenEnhancerChain enhancerChain new TokenEnhancerChain(); ListTokenEnhancer enhancers new ArrayList(); enhancers.add(customTokenEnhancer()); enhancers.add(jwtAccessTokenConverter()); enhancerChain.setTokenEnhancers(enhancers); endpoints .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(jwtTokenStore()) .tokenEnhancer(enhancerChain) .accessTokenConverter(jwtAccessTokenConverter()); }顺序很关键自定义增强器要放在 JWT 转换器前面。因为 JWT 转换器是最后一步负责把前面所有增强器处理过的内容编码成 JWT。如果顺序反了自定义字段就进不了令牌。第三步配置令牌有效期。JWT 一旦签发就无法主动失效所以有效期设置要谨慎Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(my-client) .secret(passwordEncoder.encode(my-secret)) .authorizedGrantTypes(password, refresh_token) .scopes(read, write) .accessTokenValiditySeconds(7200) // 2小时 .refreshTokenValiditySeconds(604800); // 7天 }access_token 设 2 小时是个折中值太短用户频繁刷新体验差太长泄露风险高。refresh_token 设 7 天配合刷新机制能覆盖大部分使用场景。第四步验证令牌格式。启动服务后用密码模式请求令牌curl -X POST http://localhost:8080/oauth/token \ -d grant_typepasswordusernameadminpassword123456scoperead \ -u my-client:my-secret如果返回的 access_token 是eyJhbGciOiJIUzI1NiJ9.xxx.yyy这种三段式结构说明改造成功了。把中间那段拿去 Base64 解码能看到 Payload 里的字段。4.2 资源服务器如何解析 JWT资源服务器的改造相对简单核心是配置JwtTokenStore和JwtAccessTokenConverter然后在资源服务配置里启用Configuration EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { Value(${oauth2.jwt.signing-key}) private String signingKey; Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(signingKey); return converter; } Bean public TokenStore tokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); } Override public void configure(ResourceServerSecurityConfigurer resources) { resources.tokenStore(tokenStore()); } }配置好之后资源服务器收到请求时会自动从 Header 里取出 Bearer 令牌本地验签并解析出用户信息全程不需要访问授权服务器。4.3 在业务代码里读取 JWT 中的自定义字段令牌解析出来的信息会放进SecurityContext业务代码里可以通过OAuth2Authentication拿到GetMapping(/user-info) public MapString, Object getUserInfo(OAuth2Authentication authentication) { MapString, Object result new HashMap(); OAuth2AuthenticationDetails details (OAuth2AuthenticationDetails) authentication.getDetails(); MapString, Object decoded JwtHelper.decodeAndVerify( details.getTokenValue(), verifier).getClaims(); result.put(userId, decoded.get(userId)); result.put(tenantId, decoded.get(tenantId)); return result; }不过更优雅的做法是自定义一个AccessTokenConverter在解析阶段就把自定义字段映射到Authentication的 details 里业务代码直接读不用手动解码。这个我在下面 4.4 里展开。4.4 自定义 AccessTokenConverter 实现字段映射默认的DefaultAccessTokenConverter只认标准字段自定义字段会被忽略。要让它认识我们的userId、tenantId需要继承并重写public class CustomAccessTokenConverter extends DefaultAccessTokenConverter { Override public OAuth2Authentication extractAuthentication(MapString, ? claims) { OAuth2Authentication authentication super.extractAuthentication(claims); authentication.setDetails(claims); return authentication; } }把整个 claims 塞进 details业务代码就能通过authentication.getDetails()拿到所有字段。然后在JwtAccessTokenConverter里设置这个自定义转换器converter.setAccessTokenConverter(new CustomAccessTokenConverter());这一步做完授权服务器和资源服务器两侧都要配置保证编码和解码对称。4.5 参数计算与有效期设计令牌有效期不是拍脑袋定的得结合业务场景算。假设你的系统用户平均在线时长 1.5 小时那么 access_token 设 2 小时比较合适覆盖大部分会话。refresh_token 要覆盖“用户可能长时间不操作但不想重新登录”的场景一般设 7 到 30 天。这里有个经验公式可以参考access_token 有效期 平均会话时长 × 1.5refresh_token 有效期 最长免登录周期。比如电商系统用户可能一周才来一次refresh_token 就得设长一点否则每次都要重新登录。提示JWT 无法主动失效所以 access_token 有效期不宜过长。如果业务对安全性要求高可以缩短到 30 分钟配合 refresh_token 自动续期用户无感知。5. 常见问题与排查技巧实录5.1 令牌解析失败签名不匹配这是最常见的问题报错信息通常是JWT signature does not match locally computed signature。原因基本就一个授权服务器和资源服务器的签名密钥不一致。排查步骤检查两侧配置文件里的signing-key是否完全相同注意有没有多余的空格确认两侧用的算法一致都是 HS256如果密钥是从配置中心拉的确认拉取的是同一个配置项我踩过一次坑授权服务器的密钥末尾多了个换行符肉眼看不出来结果验签一直失败。后来用trim()处理才解决。所以密钥注入后建议做一次 trim。5.2 自定义字段丢失令牌里明明加了userId资源服务器却读不到。这种情况通常是AccessTokenConverter没配对。默认转换器会丢弃非标准字段必须自定义一个继承DefaultAccessTokenConverter的类并且两侧都要配置。还有一种可能是 TokenEnhancer 的顺序问题前面提过自定义增强器必须在 JWT 转换器之前。顺序错了字段加了个寂寞。5.3 令牌体积过大导致请求头超限JWT 放在 Authorization Header 里传输某些网关或服务器对 Header 大小有限制常见是 8KB。如果 Payload 塞了太多字段或者权限列表特别长就可能超限。解决办法精简 Payload只放高频字段权限列表如果太长考虑只放角色标识具体权限在资源服务器本地查检查网关的 Header 大小配置必要时调大5.4 时钟偏移导致令牌提前失效JWT 的exp字段依赖服务器时间。如果授权服务器和资源服务器的系统时间有偏差可能出现“刚签发的令牌就过期”或者“已过期的令牌还能用”的诡异现象。排查方法对比两台服务器的date命令输出。解决方法是配置 NTP 时间同步保证集群内时间一致。这个坑比较隐蔽因为报错信息不会直接提示时间问题。5.5 常见问题速查表问题现象可能原因排查方向签名验证失败密钥不一致对比两侧 signing-key自定义字段读不到转换器未配置检查 AccessTokenConverter令牌刚发就过期服务器时钟偏移检查 NTP 同步请求头超限Payload 过大精简字段或调大 Header 限制刷新令牌报错refresh_token 配置缺失检查 grant_type 和有效期5.6 几个独家避坑心得第一开发阶段把 JWT 的 Payload 打印出来看。很多人配完了不知道令牌里到底有啥其实把中间那段 Base64 解码一下一目了然。我习惯在测试环境加个日志把解析后的 claims 打出来排查问题效率翻倍。第二密钥轮换要提前设计。如果业务要求定期换密钥直接换会导致所有已签发令牌失效。正确做法是让JwtAccessTokenConverter同时持有新旧两把密钥验签时依次尝试。Spring 的JwtAccessTokenConverter支持设置多个 verifier这个特性在密钥轮换时很有用。第三别在 Payload 里放敏感信息。前面强调过JWT 只是编码不是加密。我见过有人把手机号、邮箱放进去这在安全审计时是要被点名的。敏感信息要么不放要么用 JWE加密的 JWT但 JWE 会带来额外的复杂度一般业务用不上。第四测试环境用短有效期。开发调试时把 access_token 设成 5 分钟能快速验证刷新逻辑是否正常。等上线前再改回正常值。这个习惯能帮你提前发现刷新令牌的各种边界问题。6. 令牌刷新与无状态认证的配合6.1 刷新令牌的完整流程JWT 的 access_token 有效期短过期后需要用 refresh_token 换新的。这个流程在 OAuth2 里是标准的curl -X POST http://localhost:8080/oauth/token \ -d grant_typerefresh_tokenrefresh_tokenxxx \ -u my-client:my-secret授权服务器验证 refresh_token 有效后签发新的 access_token。注意 refresh_token 本身也可以设计成 JWT但一般不建议因为它需要支持主动失效用户登出时作废用不透明令牌配合存储更合适。6.2 无状态认证的边界在哪里JWT 带来的无状态特性很诱人但也不是万能的。它解决的是“资源服务器不用回查授权服务器”这个问题但并没有解决“令牌主动失效”的问题。如果你的业务需要以下能力就得额外设计强制下线需要维护一个令牌黑名单资源服务器验签后还要查黑名单单点登录互踢需要记录用户当前有效的令牌标识权限实时变更JWT 里的权限是签发时快照变更后要等令牌过期才生效这些场景下纯无状态就不够了得引入 Redis 之类的存储做补充。我的建议是能用无状态就用无状态确实需要强控制再引入状态不要为了“看起来高级”而过度设计。6.3 性能对比实测改造前后我做过一次简单的压测对比场景是资源服务器校验令牌并返回用户信息指标默认不透明令牌JWT 令牌单次校验耗时约 8-15ms含远程调用约 0.5-2ms本地验签授权服务器 QPS 压力高每次校验都打过来低只在签发时交互水平扩展难度需要共享 TokenStore无状态随便扩数据是本地环境测的绝对值参考意义有限但趋势很明显JWT 把校验成本从“网络往返”降到了“本地计算”这个量级差异在高并发下会被放大。7. 一些延伸思考与个人体会JWT 这套东西用下来最大的感受是它把复杂度从运行时转移到了设计时。默认令牌简单粗暴但每次校验都要付出性能代价JWT 性能好但你要提前想清楚放什么字段、有效期设多长、密钥怎么管、失效怎么做。这些决策一旦定下来后期改动成本不低。我个人在实际项目里的做法是access_token 用 JWTrefresh_token 用不透明令牌。前者追求校验性能后者追求可控性。这样既享受了 JWT 的无状态优势又保留了主动失效的能力。这个组合在好几个项目里跑下来都比较稳。另外Payload 里的字段设计要克制。我见过有人把整个用户对象序列化进去令牌直接飙到几 KB每次请求都背着这么个大包袱得不偿失。原则是高频读取的放进去低频的按需查。userId、tenantId 这种每次都要用的放进去用户昵称、头像这种展示用的让前端单独查一次就行。最后分享一个小技巧调试 JWT 的时候用在线的 JWT 解码工具注意别把生产令牌贴上去能快速看清结构。本地的话写个简单的 Base64 解码脚本也够用。关键是养成“先看令牌内容再排查”的习惯很多问题看一眼 Payload 就明白了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑