Spring Boot 3.2 OAuth2资源服务器JWT过期后仍有效问题排查
问题描述
我正在学习使用JWT构建无状态后端API,这是一个学习用的测试项目,采用Spring Boot的OAuth2资源服务器。由于只有单个后端,无需公钥,仅使用openssl生成的密钥配置在application.properties文件中。官方文档不够完善,我花费了大量时间才理清基础配置。目前已成功实现基于UsernamePasswordAuthentication的用户登录流程,并签发了测试用的、有效期为2分钟的JWT,但该Token在过期后长达10分钟内仍可正常访问受保护接口,请问这是什么原因?
我使用LocalDateTime处理时间未出现时差问题,但Token始终不失效,查看源码也未找到过期校验的相关逻辑。
相关代码
SecurityConfig.java
@Configuration @EnableWebSecurity(debug = true) @EnableMethodSecurity public class SecurityConfig { @Autowired private MuserDetailsService muserDetailsService; private JwtPropHolder jwtPropHolder; public SecurityConfig(JwtPropHolder jwtPropHolder) { this.jwtPropHolder = jwtPropHolder; } @Bean public SecurityFilterChain createSecurityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2ResourceServer(configurer -> { configurer.jwt(jwtConfigurer -> { jwtConfigurer.jwtAuthenticationConverter(getMyJwtAuthenticationConverter()); }); }) .build(); } @Bean public PasswordEncoder createPasswordEncoder() { return NoOpPasswordEncoder.getInstance(); } @Bean public JwtDecoder createJwtDecoder() { NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(jwtPropHolder.getSecretKey()).build(); OAuth2TokenValidator<Jwt> withClockSkwe = new DelegatingOAuth2TokenValidator<>( new JwtTimestampValidator(Duration.ofSeconds(0)) ); jwtDecoder.setJwtValidator(withClockSkwe); return jwtDecoder; } @Bean public AuthenticationManager getAuthenticationManager(JwtDecoder jwtDecoder) { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setPasswordEncoder(NoOpPasswordEncoder.getInstance()); daoAuthenticationProvider.setUserDetailsService(muserDetailsService); JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(createJwtDecoder()); return new ProviderManager( daoAuthenticationProvider, jwtAuthenticationProvider ); } @Bean public Converter<Jwt, AbstractAuthenticationToken> getMyJwtAuthenticationConverter() { return new MyJwtConverter(); } @Bean public RoleHierarchy roleHierarchy() { RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD"); return roleHierarchy; } @Bean public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() { DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); expressionHandler.setRoleHierarchy(roleHierarchy()); return expressionHandler; } public JwtAuthenticationConverter oldJwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); //This line throws the class cast exception but then grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; } } @Configuration @Getter @Setter @ConfigurationProperties(prefix = "jwt") public class JwtPropHolder { private String key; private String algorithm; private Integer randomByteSize; public SecretKey getSecretKey() { return new SecretKeySpec(key.getBytes(), algorithm); } }
JwsService.java
@Service public class JwsService { @Autowired private JwtPropHolder jwtPropHolder; private JWSObject jwsObject; private String jwsSerialised; @Autowired private ObjectMapper objectMapper; public void createJws(Map<String, Object> claims) throws JOSEException { jwsObject = new JWSObject( new JWSHeader(JWSAlgorithm.parse(jwtPropHolder.getAlgorithm())), new Payload(claims) ); JWSSigner signer = new MACSigner(jwtPropHolder.getSecretKey()); jwsObject.sign(signer); jwsSerialised = jwsObject.serialize(); } public void createJwsFromClaimsSet(Map<String, Object> claims) throws JOSEException { JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(); if (claims.containsKey("sub")){ builder.claim("sub", claims.get("sub")); } if (claims.containsKey("iss")){ builder.claim("iss", claims.get("iss")); } if (claims.containsKey("exp")){ builder.claim("exp", claims.get("exp")); } if (claims.containsKey("iat")) { builder.claim("iat", claims.get("iat")); } builder.claim("authorities", claims.get("authorities")); } /** * Returns the string token * @return */ public String getJwsSerialised() { return jwsSerialised; } /** * Returns the whole JWSObject * @return */ public JWSObject getJwsObject() { return jwsObject; } }
AuthenticationApi.java
@RestController @Slf4j public class AuthenticationApi { private AuthenticationManager authenticationManager; private JwsService jwsService; public AuthenticationApi(AuthenticationManager authenticationManager, JwsService jwsService) { this.authenticationManager = authenticationManager; this.jwsService = jwsService; } @PostMapping("/login") public LoginResponse login(@RequestBody LoginRequest loginRequest) throws JOSEException { Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated( loginRequest.username(), loginRequest.password() ); Authentication authenticated = authenticationManager.authenticate(authentication); /** * I can't just pass a date, * And all time related things need to be in numeric seconds format as per JWT spec, * So I am converting LocalDateTime and then working with it as its more accurate, * And does not have that one hour discrepency * First I will create the LocalDateTimes, so I can send these back to the client, * Then I will create the seconds version and send it in the JWT: */ LocalDateTime now = LocalDateTime.now(); LocalDateTime expiry = LocalDateTime.now().plus(2, ChronoUnit.MINUTES); Long nowInSeconds = now.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); Long expiryInSeconds = expiry.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); Map<String, Object> claims = new HashMap<>(); claims.put("authorities", authenticated.getAuthorities()); claims.put("name", authenticated.getName()); claims.put("exp", expiryInSeconds); claims.put("iat", nowInSeconds); jwsService.createJws(claims); return new LoginResponse( jwsService.getJwsSerialised(), jwsService.getJwsObject(), authenticated, now, expiry ); } }
问题原因及解决方案
核心原因:JWT时间戳单位错误
JWT标准明确要求exp(过期时间)和iat(签发时间)必须是秒级时间戳,但你在AuthenticationApi中错误使用了toEpochMilli()获取毫秒级时间戳。这就相当于把Token的过期时间设置成了「当前时间+2分钟」的毫秒数,实际过期时间会变成2分钟×1000=2000分钟(约33小时),所以10分钟内Token完全处于有效期内,自然不会失效。
修复步骤
修改AuthenticationApi中的时间戳转换代码,将毫秒转成秒:
// 替换原来的毫秒转换代码 Long nowInSeconds = now.atZone(ZoneId.systemDefault()).toInstant().getEpochSecond(); Long expiryInSeconds = expiry.atZone(ZoneId.systemDefault()).toInstant().getEpochSecond();
额外验证点
你的JwtDecoder配置中已经正确设置了JwtTimestampValidator并将时钟偏差设为0,这部分无需修改。修复时间戳单位后,过期校验会正常触发。如果问题仍然存在,可以检查:
- 确认自定义的
MyJwtConverter没有篡改exp声明的解析逻辑 - 利用已开启的Spring Security debug模式(
@EnableWebSecurity(debug = true)),查看请求时的JWT校验日志,确认exp声明的解析值是否符合预期
内容的提问来源于stack exchange,提问作者theMyth
相关产品推荐
相关产品推荐

