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);
}
}
笔记: