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

Spring Boot OAuth2无法通过Refresh Token自动刷新AccessToken问题排查

问题

搭建了一套基础的OAuth2客户端架构,当AccessToken过期时,尽管存在有效的Refresh Token,系统仍无法自动刷新授权令牌。

补充信息

逻辑流程:

  1. MVC请求访问index接口
  2. 从客户端注册表获取令牌
  3. 调用服务方法
  4. 遍历每个pilot(子对象)
  5. 使用Controller传入的令牌,通过RestClient.get()调用REST API获取pilot详情,该REST API是独立的OAuth2资源服务器Spring Boot项目,与客户端使用同一Keycloak实例。

Spring Boot版本:3.2.5

application.yaml

security:
  oauth2:
    client:
      provider:
        keycloak:
          issuer-uri: ${keycloak-issuer}
      registration:
        keycloak-confidential-user:
          authorization-grant-type: authorization_code
          client-name: a local Keycloak instance
          client-id: ${keycloak.id}
          client-secret: ${keycloak.password}
          provider: keycloak
          scope: openid,profile,email,offline_access

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {
    @Bean
    SecurityFilterChain clientSecurityFilterChain(HttpSecurity http) throws Exception {
        http.oauth2Login(withDefaults());
        http.oauth2Client(withDefaults());
        http.logout(withDefaults());
        // @formatter:off
        http.authorizeHttpRequests(ex -> ex
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
                .anyRequest().authenticated());
        // @formatter:on
        return http.build();
    }
}

Controller.java

private final OAuth2AuthorizedClientRepository clientRepository;

@GetMapping("")
public String index(@AuthenticationPrincipal DefaultOidcUser principal,
                    Authentication auth,
                    HttpServletRequest servletRequest,
                    Model model) {
    String token = getEaToken(auth, servletRequest);
    model.addAttribute("pilots",
            pilotService.getPilotListForUser(UUID.fromString(principal.getName()), token));
    return "pilot/list";
}

private String getEaToken(Authentication auth, HttpServletRequest servletRequest) {
    OAuth2AuthorizedClient authorizedClient = clientRepository.loadAuthorizedClient(
            "keycloak-confidential-user", auth, servletRequest);

    if (authorizedClient == null) {
        log.warn("Authorized client could not be loaded\nauth[{}]\nservlet[{}]", auth, servletRequest);
        return null;
    }

    String token = authorizedClient.getAccessToken().getTokenValue();
    log.debug("eaAuthToken is null [{}]", token == null);
    return token;
}

Service.java

@Override
public List<PilotListInfo> getPilotListForUser(UUID userId, String eaAuthToken) {
    List<PilotListInfo> pilots = pilotRepository.findByEveAssistUser_UniqueUserOrderByNameAsc(
            userId, Pageable.ofSize(10));

    if (eaAuthToken != null) {
        for (PilotListInfo pilot : pilots) {
            PilotPublicDto singlePilot = getSinglePilotDetails(pilot.getEvePilotId(), eaAuthToken);
            if (singlePilot != null)
                pilotMapper.partialUpdateDetails(singlePilot, pilot);
        }
    }
    return pilots;
}

@Override
public PilotPublicDto getSinglePilotDetails(Long evePilotId, String eaAuthToken) {
    ResponseEntity<PilotPublicDto> publicData = eaApiRestClient.get()
            .uri(ESI_CHAR_PUBLIC.formatted(evePilotId))
            .header("Authorization", "Bearer %s".formatted(eaAuthToken))
            .retrieve()
            .toEntity(PilotPublicDto.class);
    if (publicData.getStatusCode().is2xxSuccessful())
        return publicData.getBody();

    log.warn("unable to get public data for pilot with Eve Pilot Id [{}]", evePilotId);
    return null;
}

调试信息:活跃的OAuth2AuthorizedClientManager同时持有AccessToken和Refresh Token,且authorizedClientProvider可处理Refresh Token。


原因分析

当前代码直接从OAuth2AuthorizedClientRepository加载客户端并提取AccessToken,但没有触发Spring Security OAuth2客户端的自动令牌刷新机制。

Spring Security的令牌自动刷新逻辑由OAuth2AuthorizedClientManager(默认实现为DefaultOAuth2AuthorizedClientManager)处理,它会在令牌过期时自动调用刷新流程,并将刷新后的令牌持久化回仓库。而直接调用clientRepository.loadAuthorizedClient()只是获取当前存储的令牌,不会检查令牌是否过期,也不会触发刷新操作。

此外,手动传递令牌到服务层调用RestClient的方式,绕过了Spring Security提供的OAuth2拦截器,这些组件原本可以自动处理令牌的获取和刷新。


修复方案

方案1:使用OAuth2AuthorizedClientManager处理令牌获取与刷新

修改Controller中的令牌获取逻辑,通过OAuth2AuthorizedClientManager触发自动刷新:

步骤1:注入OAuth2AuthorizedClientManager

在Controller中添加依赖注入:

private final OAuth2AuthorizedClientManager authorizedClientManager;

public YourController(OAuth2AuthorizedClientRepository clientRepository, OAuth2AuthorizedClientManager authorizedClientManager) {
    this.clientRepository = clientRepository;
    this.authorizedClientManager = authorizedClientManager;
}

步骤2:修改getEaToken方法

private String getEaToken(Authentication auth, HttpServletRequest servletRequest) {
    OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("keycloak-confidential-user")
            .principal(auth)
            .attribute(HttpServletRequest.class.getName(), servletRequest)
            .build();

    OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
    
    if (authorizedClient == null) {
        log.warn("Authorized client could not be loaded\nauth[{}]\nservlet[{}]", auth, servletRequest);
        return null;
    }

    String token = authorizedClient.getAccessToken().getTokenValue();
    log.debug("eaAuthToken is null [{}]", token == null);
    return token;
}

authorizedClientManager.authorize()方法会自动完成:

  • 检查当前令牌是否过期
  • 如果过期且存在Refresh Token,自动调用刷新流程获取新的AccessToken
  • 将刷新后的授权客户端持久化回OAuth2AuthorizedClientRepository

方案2:使用Spring Security的RestClient OAuth2拦截器(推荐)

直接使用配置了OAuth2客户端支持的RestClient,无需手动传递令牌,拦截器会自动处理令牌生命周期:

步骤1:配置OAuth2-aware的RestClient

创建支持OAuth2的RestClient Bean:

@Bean
RestClient eaApiRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
    OAuth2ClientCredentialsRestClientCustomizer customizer = new OAuth2ClientCredentialsRestClientCustomizer(authorizedClientManager);
    return RestClient.builder()
            .baseUrl("你的API基础URL")
            .apply(customizer)
            .build();
}

步骤2:修改Service层代码,移除手动令牌传递

@Override
public List<PilotListInfo> getPilotListForUser(UUID userId) {
    List<PilotListInfo> pilots = pilotRepository.findByEveAssistUser_UniqueUserOrderByNameAsc(
            userId, Pageable.ofSize(10));

    for (PilotListInfo pilot : pilots) {
        PilotPublicDto singlePilot = getSinglePilotDetails(pilot.getEvePilotId());
        if (singlePilot != null)
            pilotMapper.partialUpdateDetails(singlePilot, pilot);
    }
    return pilots;
}

@Override
public PilotPublicDto getSinglePilotDetails(Long evePilotId) {
    ResponseEntity<PilotPublicDto> publicData = eaApiRestClient.get()
            .uri(ESI_CHAR_PUBLIC.formatted(evePilotId))
            .retrieve()
            .toEntity(PilotPublicDto.class);
    if (publicData.getStatusCode().is2xxSuccessful())
        return publicData.getBody();

    log.warn("unable to get public data for pilot with Eve Pilot Id [{}]", evePilotId);
    return null;
}

步骤3:简化Controller代码

@GetMapping("")
public String index(@AuthenticationPrincipal DefaultOidcUser principal, Model model) {
    model.addAttribute("pilots",
            pilotService.getPilotListForUser(UUID.fromString(principal.getName())));
    return "pilot/list";
}

这种方式完全由Spring Security处理令牌的获取、刷新和注入,无需手动干预,代码更简洁且不易出错。


内容的提问来源于stack exchange,提问作者Timothy Vogel

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.22 21:35:05