2

我已经将 Spring Cloud Gateway 与 OAuth2 服务器集成在一起。它适用于单实例网关。这是我的安全配置。

@EnableWebFluxSecurity
public class GatewaySecurityConfiguration {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http
                .authorizeExchange().pathMatchers("/user/v3/api-docs", "/actuator/**").permitAll()
                .anyExchange().authenticated()
            .and()
                .oauth2Login()
            .and()
                .csrf().disable();
        return http.build();
    }

但是,当我将网关扩展到 2 个实例时,一些请求可以正常工作,但是一些请求返回 401。

    load balancer (kubernetes nodeport service)
       /    \
  gateway   gateway
       \    /
(microservice clusters)

当我登录网关的第一个实例时,主体对象已成功创建并将会话分配给 redis。如果下一个请求到达第二个实例,它会返回 401,因为它没有主体。

我怎么解决这个问题?

ps:我正在使用redis进行Web会话,以共享网关之间的会话信息。

4

2 回答 2

2

TL;博士

您可以通过 WebSession 在 Redis 上共享会话主体信息。但是您不能共享访问令牌(JWT),因为它们存储在服务器的内存中。

  • 解决方案 1:您的请求应始终发送到您登录的服务器。(详情如下)
  • 解决方案 2:实现将会话存储在 redis 中的新 ReactiveOAuth2AuthorizedClientService bean。(详情如下)

长答案

来自 Spring Cloud 文档(https://cloud.spring.io/spring-cloud-static/Greenwich.SR5/multi/multi__more_detail.html);

TokenRelayGatewayFilterFactory 使用的 ReactiveOAuth2AuthorizedClientService 的默认实现使用内存数据存储。如果您需要更强大的解决方案,您将需要提供自己的实现 ReactiveOAuth2AuthorizedClientService。

您知道的第一件事:当您成功登录时,oauth2 服务器返回访问令牌(作为 jwt),服务器创建会话并将此会话映射到 ConcurrentHashMap 上的访问令牌(authorizedClients 实例 InMemoryReactiveOAuth2AuthorizedClientService 类)。

当您请求 API Gateway 使用您的 session id 访问微服务时,访问令牌(jwt)由网关中的 TokenRelayGatewayFilterFactory 解析,并在 Authorization 标头中设置此访问令牌,并将请求转发到微服务。

那么,让我解释一下 TokenRelayGatewayFilterFactory 是如何工作的(假设您通过 Redis 使用 WebSession,并且您有 2 个网关实例并且您在 instance-1 登录。)

  • 如果您的请求转到实例 1,则主体通过会话 id 从 redis 获取,然后在过滤器中调用 authorizedClientRepository.loadAuthorizedClient(..)。此存储库是 AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository 对象的实例。isPrincipalAuthenticated() 方法返回 true,因此流程继续进行 authorizedClientService.loadAuthorizedClient()。该服务被定义为 ReactiveOAuth2AuthorizedClientService 接口,它只有一个实现(InMemoryReactiveOAuth2AuthorizedClientService)。这个实现有 ConcurrentHashMap(key: principal object, value: JWT)
  • 如果您的请求转到 instance-2,则上述所有流程均有效。但是提醒一下,ConcurrentHashMap 没有对 principal 的访问令牌,因为访问令牌存储在 instance-1 的 ConcurrentHashMap 中。因此,访问令牌为空,然后您的请求在没有 Authorization 标头的情况下向下游发送。你会得到 401 Unauthorized。

解决方案-1

因此,您的请求应始终发送到您登录的服务器以获取有效的访问令牌。

  • 如果你使用 NGINX 作为负载均衡器,那么在upstream中使用ip_hash
  • 如果您使用 kubernetes 服务作为负载均衡器,则在session affinity中使用ClientIP

解决方案-2

InMemoryReactiveOAuth2AuthorizedClientService 只是 ReactiveOAuth2AuthorizedClientService 的实现。因此,创建使用 Redis 的新实现,然后执行bean。

@RequiredArgsConstructor
@Slf4j
@Component
@Primary
public class AccessTokenRedisConfiguration implements ReactiveOAuth2AuthorizedClientService {

    private final SessionService sessionService;

    @Override
    @SuppressWarnings("unchecked")
    public <T extends OAuth2AuthorizedClient> Mono<T> loadAuthorizedClient(String clientRegistrationId, String principalName) {
        log.info("loadAuthorizedClient for user {}", principalName);
        Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
        Assert.hasText(principalName, "principalName cannot be empty");

        // TODO: When changed immutability of OAuth2AuthorizedClient, return directly object without map.
        return (Mono<T>) sessionService.getSessionRecord(principalName, "accessToken").cast(String.class)
                .map(mapper -> {
                    return new OAuth2AuthorizedClient(clientRegistration(), principalName, accessToken(mapper));
        });
    }

    @Override
    public Mono<Void> saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
        log.info("saveAuthorizedClient for user {}", principal.getName());
        Assert.notNull(authorizedClient, "authorizedClient cannot be null");
        Assert.notNull(principal, "principal cannot be null");

        return Mono.fromRunnable(() -> {
            // TODO: When changed immutability of OAuth2AuthorizedClient , persist OAuthorizedClient instead of access token.
            sessionService.addSessionRecord(principal.getName(), "accessToken", authorizedClient.getAccessToken().getTokenValue());
        });
    }

    @Override
    public Mono<Void> removeAuthorizedClient(String clientRegistrationId, String principalName) {
        log.info("removeAuthorizedClient for user {}", principalName);
        Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
        Assert.hasText(principalName, "principalName cannot be empty");
        return null;
    }

    private static ClientRegistration clientRegistration() {
        return ClientRegistration.withRegistrationId("login-client")
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .clientId("dummy").registrationId("dummy")
                .redirectUriTemplate("dummy")
                .authorizationUri("dummy").tokenUri("dummy")
                .build();
    }

    private static OAuth2AccessToken accessToken(String value) {
        return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, value, null, null);
    }

}

笔记:

于 2020-07-30T09:00:18.143 回答
1

TokenRelayGatewayFilterFactory使用内存数据存储来存储包含 (JWT) 访问令牌的 OAuth2AuthorizedClient 此数据存储不在多个网关之间共享。

要通过 Redis 与 Spring Session 共享 OAuth2AuthorizedClient 信息,请提供以下配置:

@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository() {
    return new HttpSessionOAuth2AuthorizedClientRepository();
}

对于反应式 WebSession:

@Bean
public ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
    return new WebSessionServerOAuth2AuthorizedClientRepository();
}

有关此配置的更多信息,请参见https://github.com/spring-projects/spring-security/issues/7889

于 2021-01-15T12:27:14.557 回答