You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 17:55:56