通过 Spring 云网关注销不适用于 Spring 安全、OIDC 和 Keycloak

Logout via Spring Cloud Gateway does not work with Spring Security, OIDC and Keycloak

我是 运行 一个 Spring 云网关,它使用 Keycloak 处理 OAuth2 身份验证。单页应用程序 (SPA) 的登录部分工作正常,但现在我无法注销。

这是我的想法:

  1. SPA 在网关上将 POST 发送到 /logout
  2. 网关使会话及其 SESSION cookie 无效。
  3. 网关联系 Keycloak 的 end_session_endpoint,即 http://localhost:8080/auth/realms/demo/protocol/openid-connect/logout
  4. 用户被重定向到 SPA。

这是我当前使用 Webflux 的安全配置。该代码基于此处提到的示例和信息:

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {

    private static final String FRONTEND_URL = "http://localhost:8093"

    @Autowired
    private final ReactiveClientRegistrationRepository clientRegistrationRepository;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity) {
        httpSecurity
                .csrf().disable()
                .authorizeExchange()
                .anyExchange().authenticated()
                .and()
                .oauth2Login()
                .authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler(FRONTEND_URL))
                .and()
                .exceptionHandling().authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))
                .and()
                .logout()
                .logoutHandler(logoutHandler())
                .logoutSuccessHandler(oidcLogoutSuccessHandler());
        return httpSecurity.build();
    }

    private ServerLogoutHandler logoutHandler() {
        return new DelegatingServerLogoutHandler(new WebSessionServerLogoutHandler(), new SecurityContextServerLogoutHandler());
    }

    private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedServerLogoutSuccessHandler logoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        logoutSuccessHandler.setPostLogoutRedirectUri(FRONTEND_URL);
        return logoutSuccessHandler;
    }
}

/logout 发送 POST 时,调试日志显示如下:

athPatternParserServerWebExchangeMatcher : Request 'POST /logout' doesn't match 'null /oauth2/authorization/{registrationId}'
athPatternParserServerWebExchangeMatcher : Request 'POST /logout' doesn't match 'null /login/oauth2/code/{registrationId}'
o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/logout', method=POST}
athPatternParserServerWebExchangeMatcher : Checking match of request : '/logout'; against '/logout'
o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
ebSessionServerSecurityContextRepository : Found SecurityContext 'SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [foo], Granted Authorities: [[ROLE_USER, SCOPE_email, SCOPE_profile]], User Attributes: [{sub=a8375794-3dae-4d4f-ae11-2b17bfb03992, email_verified=false, realm_access={roles=[DEFAULT_USER]}, name=foo bar, preferred_username=foo, given_name=foo, family_name=bar, email=foo@example.com}], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER, SCOPE_email, SCOPE_profile]]]' in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@560ddd31'
o.s.s.w.s.a.logout.LogoutWebFilter       : Logging out user 'OAuth2AuthenticationToken [Principal=Name: [foo], Granted Authorities: [[ROLE_USER, SCOPE_email, SCOPE_profile]], User Attributes: [{sub=a8375794-3dae-4d4f-ae11-2b17bfb03992, email_verified=false, realm_access={roles=[DEFAULT_USER]}, name=Foo Bar, preferred_username=foo, given_name=foo, family_name=bar, email=foo@example.com}], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER, SCOPE_email, SCOPE_profile]]' and transferring to logout destination
ebSessionServerSecurityContextRepository : Removed SecurityContext stored in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@560ddd31'
o.s.s.w.s.DefaultServerRedirectStrategy  : Redirecting to '/login?logout'
athPatternParserServerWebExchangeMatcher : Request 'GET /login' doesn't match 'null /oauth2/authorization/{registrationId}'
athPatternParserServerWebExchangeMatcher : Request 'GET /login' doesn't match 'null /login/oauth2/code/{registrationId}'
o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/logout', method=POST}
athPatternParserServerWebExchangeMatcher : Request 'GET /login' doesn't match 'POST /logout'
o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
a.DelegatingReactiveAuthorizationManager : Checking authorization on '/login' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@1b45f1c
ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@790f8540'
o.s.s.w.s.a.AuthorizationWebFilter       : Authorization failed: Access Denied
ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@790f8540'

所以,看起来网关成功地使会话无效了。但这里有两个我不明白的观察结果:

  1. Keycloak 似乎仍有该用户的会话。 Keycloak cookie 仍然存在,重新加载 SPA 会直接基于这些 cookie 创建一个新会话。
  2. POST 到 /logout 重定向到 /login?logout 被 401 拒绝。我的理解是,重定向应该发生在 FRONTEND_URL = "http://localhost:8093"?

也许您正在做 ajax post 而不是表格 post?服务器无法在 ajax 调用时将您的浏览器重定向到 keycloak。