OAuth2 与多个网关实例共享主体对象
OAuth2 Share Principal Object with Multiple Gateway Instances
我已将 Spring 云网关与 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 个实例时,一些请求可以正常工作,但是一些请求 return 401。
load balancer (kubernetes nodeport service)
/ \
gateway gateway
\ /
(microservice clusters)
当我登录网关的第一个实例时,成功创建主体对象并将会话分配给redis。如果下一个请求是第二个实例,它 returns 401 因为它没有主体。
我该如何解决这个问题?
ps: 我正在使用 redis 进行网络会话以在网关之间共享会话信息。
TL;DR
您可以通过 WebSession 在 Redis 上共享 session 主要信息。但是您不能共享访问令牌 (JWT),因为它们存储在 in-memory 服务器上。
- 解决方案 1:您的请求应始终发送到您登录的服务器。(详情如下)
- 解决方案 2:实施新的 ReactiveOAuth2AuthorizedClientService bean,将 sessions 存储在 redis 中。 (下面也有详细信息)
长答案
来自 Spring 云文档 (https://cloud.spring.io/spring-cloud-static/Greenwich.SR5/multi/multi__more_detail.html);
The default implementation of ReactiveOAuth2AuthorizedClientService
used by TokenRelayGatewayFilterFactory uses an in-memory data store.
You will need to provide your own implementation
ReactiveOAuth2AuthorizedClientService if you need a more robust
solution.
你知道的第一件事:当你登录成功时,oauth2 服务器返回访问令牌(如 jwt),服务器创建 session 并将此 session 映射到访问令牌ConcurrentHashMap(authorizedClients 实例 InMemoryReactiveOAuth2AuthorizedClientService class)。
当你请求API网关使用你的sessionid访问微服务时,访问令牌(jwt)由网关中的TokenRelayGatewayFilterFactory解析,这个访问令牌在授权中设置header,请求正在转发到微服务。
那么,让我解释一下 TokenRelayGatewayFilterFactory 是如何工作的(假设您通过 Redis 使用 WebSession 并且您有 2 个网关实例并且您在 instance-1 登录。)
- 如果您的请求转到 instance-1,主体将通过 redis 的 session id 返回,然后在过滤器中调用 authorizedClientRepository.loadAuthorizedClient(..)。此存储库是 AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository object 的实例。 isPrincipalAuthenticated() 方法 returns 为真,因此流程继续 authorizedClientService.loadAuthorizedClient()。该服务被定义为 ReactiveOAuth2AuthorizedClientService 接口,它只有一个实现(InMemoryReactiveOAuth2AuthorizedClientService)。这个实现有 ConcurrentHashMap(key: principal object, value: JWT)
- 如果您的请求转到 instance-2,则以上所有流程均有效。但提醒一下,ConcurrentHashMap 没有访问主体的访问令牌,因为访问令牌存储在实例 1 的 ConcurrentHashMap 中。因此,访问令牌为空,然后您的请求在未经授权的情况下向下游发送 header。你会得到 401 Unauthorized.
解法-1
因此,您的请求应始终发送到您登录的服务器以获取有效的访问令牌。
- 如果您使用 NGINX 作为负载均衡器,则在 upstream.
中使用 ip_hash
- 如果你使用kubernetes服务作为负载均衡器,那么在session affinity.
中使用ClientIP
解法-2
InMemoryReactiveOAuth2AuthorizedClientService 只是 ReactiveOAuth2AuthorizedClientService 的实现。因此,创建使用 Redis 的新实现,然后执行 primary 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);
}
}
备注:
- SessionService 是我的自定义 class,它与 reactivehashoperations 实例交互 redis。
- 最好的方法是存储 OAuth2AuthorizedClient 而不是访问令牌。但是,现在太难了(https://github.com/spring-projects/spring-security/issues/8905)
TokenRelayGatewayFilterFactory 使用内存数据存储来存储包含 (JWT) 访问令牌的 OAuth2AuthorizedClient。此数据存储不在多个网关之间共享。
要通过 Redis 与 Spring 会话共享 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
我已将 Spring 云网关与 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 个实例时,一些请求可以正常工作,但是一些请求 return 401。
load balancer (kubernetes nodeport service)
/ \
gateway gateway
\ /
(microservice clusters)
当我登录网关的第一个实例时,成功创建主体对象并将会话分配给redis。如果下一个请求是第二个实例,它 returns 401 因为它没有主体。
我该如何解决这个问题?
ps: 我正在使用 redis 进行网络会话以在网关之间共享会话信息。
TL;DR
您可以通过 WebSession 在 Redis 上共享 session 主要信息。但是您不能共享访问令牌 (JWT),因为它们存储在 in-memory 服务器上。
- 解决方案 1:您的请求应始终发送到您登录的服务器。(详情如下)
- 解决方案 2:实施新的 ReactiveOAuth2AuthorizedClientService bean,将 sessions 存储在 redis 中。 (下面也有详细信息)
长答案
来自 Spring 云文档 (https://cloud.spring.io/spring-cloud-static/Greenwich.SR5/multi/multi__more_detail.html);
The default implementation of ReactiveOAuth2AuthorizedClientService used by TokenRelayGatewayFilterFactory uses an in-memory data store. You will need to provide your own implementation ReactiveOAuth2AuthorizedClientService if you need a more robust solution.
你知道的第一件事:当你登录成功时,oauth2 服务器返回访问令牌(如 jwt),服务器创建 session 并将此 session 映射到访问令牌ConcurrentHashMap(authorizedClients 实例 InMemoryReactiveOAuth2AuthorizedClientService class)。
当你请求API网关使用你的sessionid访问微服务时,访问令牌(jwt)由网关中的TokenRelayGatewayFilterFactory解析,这个访问令牌在授权中设置header,请求正在转发到微服务。
那么,让我解释一下 TokenRelayGatewayFilterFactory 是如何工作的(假设您通过 Redis 使用 WebSession 并且您有 2 个网关实例并且您在 instance-1 登录。)
- 如果您的请求转到 instance-1,主体将通过 redis 的 session id 返回,然后在过滤器中调用 authorizedClientRepository.loadAuthorizedClient(..)。此存储库是 AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository object 的实例。 isPrincipalAuthenticated() 方法 returns 为真,因此流程继续 authorizedClientService.loadAuthorizedClient()。该服务被定义为 ReactiveOAuth2AuthorizedClientService 接口,它只有一个实现(InMemoryReactiveOAuth2AuthorizedClientService)。这个实现有 ConcurrentHashMap(key: principal object, value: JWT)
- 如果您的请求转到 instance-2,则以上所有流程均有效。但提醒一下,ConcurrentHashMap 没有访问主体的访问令牌,因为访问令牌存储在实例 1 的 ConcurrentHashMap 中。因此,访问令牌为空,然后您的请求在未经授权的情况下向下游发送 header。你会得到 401 Unauthorized.
解法-1
因此,您的请求应始终发送到您登录的服务器以获取有效的访问令牌。
- 如果您使用 NGINX 作为负载均衡器,则在 upstream. 中使用 ip_hash
- 如果你使用kubernetes服务作为负载均衡器,那么在session affinity. 中使用ClientIP
解法-2
InMemoryReactiveOAuth2AuthorizedClientService 只是 ReactiveOAuth2AuthorizedClientService 的实现。因此,创建使用 Redis 的新实现,然后执行 primary 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);
}
}
备注:
- SessionService 是我的自定义 class,它与 reactivehashoperations 实例交互 redis。
- 最好的方法是存储 OAuth2AuthorizedClient 而不是访问令牌。但是,现在太难了(https://github.com/spring-projects/spring-security/issues/8905)
TokenRelayGatewayFilterFactory 使用内存数据存储来存储包含 (JWT) 访问令牌的 OAuth2AuthorizedClient。此数据存储不在多个网关之间共享。
要通过 Redis 与 Spring 会话共享 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