Spring授权服务器与Cloud Gateway的refresh_token流程登出异常问题
我已搭建Spring Authorization Server(SAS)与Spring Cloud Gateway(SCG),整体功能正常,但在基于refresh_token的流程中发现问题:
用户登录后,SAS返回access token、oidc token(id_token)和refresh token,均存储于SCG内存中。SAS的会话cookie有效期为30分钟,SCG通过该cookie延长access token有效期;cookie过期后,SCG开始使用refresh_token刷新令牌。每次刷新请求时,SAS会更新id_token,但SCG并未同步更新其内存中的id_token。当用户发起RP-Initiated Logout(依赖方发起登出)时,SCG向SAS发送最初返回的旧id_token,被SAS判定为无效并抛出异常。
请问是否可以配置SAS不刷新id_token,或配置SCG在刷新请求时同步更新内存中的id_token?
方案一:配置SAS刷新令牌时不生成新的id_token
SAS默认在刷新令牌流程中会重新生成id_token,可通过自定义OAuth2TokenGenerator控制这一行为,复用未过期的旧id_token:
- 实现自定义IdToken生成器
@Component public class ReusableIdTokenGenerator implements OAuth2TokenGenerator<OidcIdToken> { private final DefaultIdTokenGenerator delegate; public ReusableIdTokenGenerator(JWKSource<SecurityContext> jwkSource, AuthorizationServerSettings authorizationServerSettings) { this.delegate = new DefaultIdTokenGenerator(jwkSource, authorizationServerSettings); } @Override public OidcIdToken generate(OAuth2TokenContext context) { // 仅在刷新令牌授权类型下,尝试复用原id_token if (AuthorizationGrantType.REFRESH_TOKEN.equals(context.getAuthorizationGrantType())) { OAuth2Authorization authorization = context.getAuthorization(); OidcIdToken existingIdToken = authorization.getToken(OidcIdToken.class) .map(OAuth2Token::getToken) .map(OidcIdToken::new) .orElse(null); // 原id_token未过期则直接返回 if (existingIdToken != null && Instant.now().isBefore(existingIdToken.getExpiresAt())) { return existingIdToken; } } // 其他场景(如授权码流程)使用默认生成逻辑 return delegate.generate(context); } @Override public boolean supports(OAuth2TokenContext context) { return delegate.supports(context); } }
- 在SAS配置类中替换默认生成器
在SecurityConfig中添加以下Bean:
@Bean public OAuth2TokenGenerator<OidcIdToken> idTokenGenerator(JWKSource<SecurityContext> jwkSource, AuthorizationServerSettings authorizationServerSettings) { return new ReusableIdTokenGenerator(jwkSource, authorizationServerSettings); }
方案二:配置SCG刷新令牌时同步更新id_token
SCG默认不会在刷新令牌后更新内存中的id_token,可通过两种方式实现同步:
方法1:自定义OAuth2AuthorizedClientRepository
重写保存逻辑,在刷新令牌后更新id_token:
@Component public class UpdatingAuthorizedClientRepository implements OAuth2AuthorizedClientRepository { private final InMemoryOAuth2AuthorizedClientRepository delegate = new InMemoryOAuth2AuthorizedClientRepository(); @Override public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request) { return delegate.loadAuthorizedClient(clientRegistrationId, principal, request); } @Override public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, HttpServletRequest request, HttpServletResponse response) { // 判断是否为刷新令牌后的响应 if (authorizedClient.getAccessToken().getIssuedAt().isAfter(authorizedClient.getRefreshToken().getIssuedAt())) { OAuth2AccessTokenResponse tokenResponse = (OAuth2AccessTokenResponse) request.getAttribute(OAuth2AccessTokenResponse.class.getName()); if (tokenResponse != null && tokenResponse.getAdditionalParameters().containsKey(OidcParameterNames.ID_TOKEN)) { String newIdTokenValue = tokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN).toString(); OidcIdToken newIdToken = new OidcIdToken( newIdTokenValue, authorizedClient.getAccessToken().getIssuedAt(), authorizedClient.getAccessToken().getExpiresAt(), tokenResponse.getAdditionalParameters() ); // 创建更新后的授权客户端并保存 OAuth2AuthorizedClient updatedClient = new OAuth2AuthorizedClient( authorizedClient.getClientRegistration(), authorizedClient.getPrincipalName(), authorizedClient.getAccessToken(), authorizedClient.getRefreshToken(), newIdToken ); delegate.saveAuthorizedClient(updatedClient, principal, request, response); return; } } delegate.saveAuthorizedClient(authorizedClient, principal, request, response); } @Override public void removeAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request, HttpServletResponse response) { delegate.removeAuthorizedClient(clientRegistrationId, principal, request, response); } }
方法2:监听OAuth2AuthorizedClientUpdatedEvent
通过事件监听机制,在授权客户端更新时同步id_token:
@Component public class AuthorizedClientUpdateListener { private final OAuth2AuthorizedClientRepository authorizedClientRepository; public AuthorizedClientUpdateListener(OAuth2AuthorizedClientRepository authorizedClientRepository) { this.authorizedClientRepository = authorizedClientRepository; } @EventListener public void handleAuthorizedClientUpdatedEvent(OAuth2AuthorizedClientUpdatedEvent event) { OAuth2AuthorizedClient authorizedClient = event.getAuthorizedClient(); OAuth2AccessTokenResponse tokenResponse = event.getAccessTokenResponse(); if (tokenResponse != null && tokenResponse.getAdditionalParameters().containsKey(OidcParameterNames.ID_TOKEN)) { String newIdTokenValue = tokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN).toString(); OidcIdToken newIdToken = new OidcIdToken( newIdTokenValue, authorizedClient.getAccessToken().getIssuedAt(), authorizedClient.getAccessToken().getExpiresAt(), tokenResponse.getAdditionalParameters() ); OAuth2AuthorizedClient updatedClient = new OAuth2AuthorizedClient( authorizedClient.getClientRegistration(), authorizedClient.getPrincipalName(), authorizedClient.getAccessToken(), authorizedClient.getRefreshToken(), newIdToken ); authorizedClientRepository.saveAuthorizedClient(updatedClient, event.getAuthentication(), event.getRequest(), event.getResponse()); } } }
我的SAS配置
@EnableWebSecurity public class SecurityConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http .getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 RequestMatcher endpointsMatcher = http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).getEndpointsMatcher(); http // Redirect to the login page when not authenticated from the // authorization endpoint .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) // Accept access tokens for User Info and/or Client Registration .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); } @Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .anyRequest().authenticated() ) // Form login handles the redirect to the login page from the // authorization server filter chain .formLogin(Customizer.withDefaults()); return http.build(); } @Bean public UserDetailsService userDetailsService() { UserDetails userDetails = User.withDefaultPasswordEncoder() .username("HDonev") .password("123456") .roles("DNSPR", "SIGSPR") .build(); return new InMemoryUserDetailsManager(userDetails); } @Bean public RegisteredClientRepository registeredClientRepository(PasswordEncoder passwordEncoder) { List<String> aud = List.of("http://localhost:8080"); RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("aca392b5-e4a7-43fa-aa94-3c6f5b5211f1") .clientSecret(passwordEncoder.encode("123456")) .clientName("some client name") .clientAuthenticationMethods(c -> c.addAll(List.of(ClientAuthenticationMethod.CLIENT_SECRET_POST, ClientAuthenticationMethod.CLIENT_SECRET_BASIC))) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUris(strings -> strings.addAll(Set.of("http://localhost:9090/home.html", "http://localhost:9090/login/oauth2/code/oauth"))) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(60)) .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .refreshTokenTimeToLive(Duration.ofSeconds(43200)) .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256) .reuseRefreshTokens(false) .build()) .scope(OidcScopes.OPENID) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(false) .requireProofKey(true) .setting("subsystem", "13900") .setting("client.allowed.resources", aud) .build()) .postLogoutRedirectUri("http://localhost:9090") .build(); return new InMemoryRegisteredClientRepository(registeredClient); } @Bean public OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer() { return context -> { if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN && context.getPrincipal() instanceof UsernamePasswordAuthenticationToken authenticationToken) { RegisteredClient registeredClient = context.getRegisteredClient(); Set<String> authorities = authenticationToken.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); context.getClaims().claims(claims -> { List<String> updateAUD = new ArrayList<>(); updateAUD.addAll((Collection<? extends String>) claims.get(JwtClaimNames.AUD)); updateAUD.addAll(registeredClient.getClientSettings().getSetting("client.allowed.resources")); claims.put(JwtClaimNames.AUD, updateAUD); if (!authorities.isEmpty()) { claims.put("roles", authorities); } }); } if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { UsernamePasswordAuthenticationToken authenticationToken = context.getPrincipal(); User user = (User) authenticationToken.getPrincipal(); JwtClaimsSet.Builder claims = context.getClaims(); claims.claim("user", user); claims.expiresAt(Instant.now().plusSeconds(3600)); } }; } @Bean public JWKSource<SecurityContext> jwkSource() throws GeneralSecurityException, IOException { RSAKey rsaKey = generateRsa(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } public RSAKey generateRsa() { KeyPair keyPair = this.generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); // @formatter:off return new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); // @formatter:on } private KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } return keyPair; } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } @Bean public AuthorizationServerSettings providerSettings() { return AuthorizationServerSettings.builder() .authorizationEndpoint("/oauth2/v1/authorize") .tokenEndpoint("/oauth2/v1/token") .tokenIntrospectionEndpoint("/oauth2/v1/introspect") .deviceAuthorizationEndpoint("/oauth2/v1/device_authorization") .tokenRevocationEndpoint("/oauth2/v1/revoke") .jwkSetEndpoint("/oauth2/v1/jwks") .oidcUserInfoEndpoint("/connect/v1/userinfo") .oidcLogoutEndpoint("/connect/v1/logout") .setting("settings.authorization-server.change-password-endpoint", "/oauth2/v1/change") .build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setMaxAge(Duration.ofMinutes(60)); corsConfiguration.setAllowedOrigins(List.of("http://localhost:9000")); corsConfiguration.addAllowedHeader("*"); corsConfiguration.addAllowedMethod("*"); corsConfiguration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfiguration); return source; } }
我的SCG application.yml配置
server: port: 9090 spring: application: name: gateway cloud: gateway: routes: - id: backend predicates: - Path=/api/book/** uri: http://localhost:8080/api/book filters: - TokenRelay - SaveSession - id: change-password uri: http://localhost:9000/oauth2/v1/change-password predicates: - Path=/oauth2/v1/change-password filters: - AddRequestParameter=client_id, ${spring.security.oauth2.client.registration.oauth.client-id} - AddRequestParameter=redirect_uri, http://localhost:9090/home.html - TokenRelay - id: static uri: http://localhost:5500 predicates: - Path=/** security: oauth2: client: provider: oauth: authorization-uri: http://localhost:9000/oauth2/v1/authorize issuer-uri: http://localhost:9000 jwk-set-uri: http://localhost:9000/oauth2/v1/jwks token-uri: http://localhost:9000/oauth2/v1/token user-info-uri: http://localhost:9000/connect/v1/userinfo registration: oauth: authorization-grant-type: authorization_code client-authentication-method: client_secret_basic client-id: aca392b5-e4a7-43fa-aa94-3c6f5b5211f1 client-secret: 123456 redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}' scope: openid threads: virtual: enabled: true
内容的提问来源于stack exchange,提问作者HDonev
相关产品推荐
相关产品推荐

