使用Spring Security的微服务无法注销AWS Cognito?

1,417 阅读4分钟

背景

在之前的微服务实战:基于Spring Cloud Gateway + AWS Cognito 的BFF案例一文中,我们使用 Spring Security 和 AWS Cognito 为 Spring Cloud Gateway 应用配置了OAuth2 登录。本文将重点介绍注销(登出)功能。

本文基于 Thymeleaf OAuth2 Login with Spring Security 和 AWS Cognito 之上。请务必先通读相关的文章,先对示例代码以及 Spring Security 和 Cognito 有一个基本的了解。

如何管理会话?

在 Gateway 中,我们使用 Redis 管理会话,不能使用本地内存,因为在云环境中,Gateway可能会存在多个实例,会话数据需要保存在外部存储中。我们先在build.gradle依赖中加入:

// for session
implementation "org.springframework.session:spring-session-data-redis"
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

然后在application.yaml中加入如下配置:

spring:
    session:
      store-type: redis             # Defines where the session is stored JVM or redis
      redis:
        flush-mode: immediate      #Tells spring to flush the session data immediately into redis
    redis:
      host: localhost
      port: 6379

在登录之后就可以在Redis中查看到保存的Session信息了:

> redis-cli -h localhost -p 6379

localhost:6379> keys spring*
1) "spring:session:sessions:078cfd0a-805c-4608-ad50-27101747fe45"

localhost:6379> HGETALL spring:session:sessions:078cfd0a-805c-4608-ad50-27101747fe45

// ...

使用 OpenID Connect 和 Spring Security,我们的用户将有两个会话:一个特定于应用程序(即Gateway),另一个用于身份提供者(即Cognito)。每当用户注销时,Spring Security(默认情况下)将使第一个会话无效,但我们的用户仍将在身份提供者处仍然处于登录状态。

这样的话,后续在Cognito的登录尝试都会自动登录到我们的应用程序中,而无需用户提供任何凭据。这不是我们想看到的,我们希望完全注销用户,以便他们可以轻松退出应用或者切换帐户。

Spring Security 不支持注销?

当然是支持的。

OIDC 规范定义了客户端如何在身份提供者处执行注销:OpenID Connect RP-Initiated Logout 1.0(目前处于起草状态)。

Spring Security只能注销支持 OpenID Connect RP-Initiated Logout 1.0 的身份提供者,比如Keycloak。对于这类Id Provider,只需要使用OidcClientInitiatedServerLogoutSuccessHandler就可以实现注销。

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                 ReactiveClientRegistrationRepository clientRegistrationRepository,
                                                 MyAuthorizationManager authorizationManager,
                                                 MyOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver) {
    // ...

    // Also logout at the OpenID Connect provider
    http.logout(logout -> logout.logoutSuccessHandler(new OidcClientInitiatedServerLogoutSuccessHandler(
        clientRegistrationRepository)));

    // ...

    return http.build();
}

不幸的是,AWS Cognito目前还不支持 OpenID Connect RP-Initiated Logout 1.0,不过它提供了一个注销机制。

Cognito 如何注销?

AWS Cognito定义了一个LOGOUT端点,参数如下。具体规格可以参考AWS Cognito LOGOUT endpoint

GET https://<DOMAIN_PREFIX>.auth.<AWS_REGION>.amazoncognito.com/logout?
    response_type=code&
    client_id=<YOUR_CLIENT_ID>& 
    redirect_uri=https://YOUR_APP/redirect_uri& 
    state=<STATE>& 
    scope=openid

让 Spring Security 支持Cognito

基于 Spring Security 的强大的可扩展性,我们可以定义自己的 LogoutHandler。 Spring Security 将调用这个自定义的处理程序,作为其自身注销过程的一部分(使Gateway HTTP 会话无效并清除 SecurityContextHolder)。

默认情况下,注销的端点为 /logout,我们不需要为这个端点开发Controller(Spring Security已经实现了)。 由于 AWS Cognito 需要 HTTPS 注销请求,我们可以仿照OidcClientInitiatedServerLogoutSuccessHandler的实现,写一个自己的LogoutHandler。

public class CognitoOidcLogoutSuccessHandler implements ServerLogoutSuccessHandler {

    private final String logoutUrl;
    private final ReactiveClientRegistrationRepository clientRegistrationRepository;
    private final RedirectServerLogoutSuccessHandler serverLogoutSuccessHandler =
        new RedirectServerLogoutSuccessHandler();
    private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();


    public CognitoOidcLogoutSuccessHandler(String logoutUrl,
                                           ReactiveClientRegistrationRepository clientRegistrationRepository) {
        Assert.notNull(logoutUrl, "logoutUrl cannot be null");
        Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
        this.logoutUrl = logoutUrl;
        this.clientRegistrationRepository = clientRegistrationRepository;
    }


    @Override
    public Mono<Void> onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) {

        return Mono.just(authentication)
            .filter(OAuth2AuthenticationToken.class::isInstance)
            .filter((token) -> authentication.getPrincipal() instanceof OidcUser)
            .map(OAuth2AuthenticationToken.class::cast)
            .map(OAuth2AuthenticationToken::getAuthorizedClientRegistrationId)
            .flatMap(this.clientRegistrationRepository::findByRegistrationId)
            .flatMap((clientRegistration) -> {
                String clientId = clientRegistration.getClientId();
                Set<String> scopes = clientRegistration.getScopes();
                URI postLogoutRedirectUri = postLogoutRedirectUri(exchange.getExchange().getRequest(), clientId, scopes);
                return Mono.just(postLogoutRedirectUri);
            })
            .switchIfEmpty(
                this.serverLogoutSuccessHandler.onLogoutSuccess(exchange, authentication).then(Mono.empty())
            )
            .flatMap((endpointUri) -> this.redirectStrategy.sendRedirect(exchange.getExchange(), endpointUri));
    }

    private URI postLogoutRedirectUri(ServerHttpRequest request, String clientId, Set<String> scopes) {
        if (this.logoutUrl == null) {
            return null;
        }

        UriComponents baseUrl = UriComponentsBuilder
            .fromUri(request.getURI())
            .replacePath(request.getPath().contextPath().value())
            .replaceQuery(null)
            .fragment(null)
            .build();

        return UriComponentsBuilder
            .fromUri(URI.create(logoutUrl))
            .queryParam("client_id", clientId)
            .queryParam("logout_uri", baseUrl)
            .queryParam("redirect_uri", baseUrl)
            .queryParam("response_type", "code")
            .queryParam("scope", String.join("+", scopes))
            .encode(StandardCharsets.UTF_8)
            .build()
            .toUri();
    }
}

这个实现需要 logoutUrl 作为参数。注销之后将用户重定向到我们应用程序的baseURL。 最简单的方法是将它们配置到 application.yaml,并用@Value 注入logoutUrl

cognito:
  logoutUrl: https://sc-gateway-sample.auth.ap-northeast-1.amazoncognito.com/logout

剩下的工作就是在SecurityConfig中配置我们自定义的CognitoOidcLogoutSuccessHandler了。


@Value("${cognito.logoutUrl}")
String logoutUrl;

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                 ReactiveClientRegistrationRepository clientRegistrationRepository,
                                                 MyAuthorizationManager authorizationManager,
                                                 MyOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver) {
    // ...

    // Also logout at the OpenID Connect provider
    http.logout(l```
logout -> logout.logoutSuccessHandler(new CognitoOidcLogoutSuccessHandler(
    logoutUrl,
    clientRegistrationRepository)));

    // ...

    return http.build();
}

这样,就可以一键同时注销两个会话了。

访问令牌失效后需要重新登录吗?

不需要的。

如果Refresh Token没有失效的话,Spring Security会自动更新Access Token,是不是很神奇!TokenRelayFilter会将Access Token传递给后端API。但需要注意的是,在Gateway的会话 SPRING_SECURITY_CONTEXT 中保存的ID Token并不会自动刷新。

总结

本文,介绍了如何使用Spring Security注销AWS Cognito,并给出了实现代码。

如果对你有所帮助,请点赞订阅分享,感谢!

相关文章

参考链接