Spring Boot OAuth2无法通过Refresh Token自动刷新AccessToken问题排查
搭建了一套基础的OAuth2客户端架构,当AccessToken过期时,尽管存在有效的Refresh Token,系统仍无法自动刷新授权令牌。
补充信息
逻辑流程:
- MVC请求访问index接口
- 从客户端注册表获取令牌
- 调用服务方法
- 遍历每个pilot(子对象)
- 使用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

