通过 Spring 云网关注销不适用于 Spring 安全、OIDC 和 Keycloak
Logout via Spring Cloud Gateway does not work with Spring Security, OIDC and Keycloak
我是 运行 一个 Spring 云网关,它使用 Keycloak 处理 OAuth2 身份验证。单页应用程序 (SPA) 的登录部分工作正常,但现在我无法注销。
这是我的想法:
- SPA 在网关上将 POST 发送到
/logout
。
- 网关使会话及其
SESSION
cookie 无效。
- 网关联系 Keycloak 的
end_session_endpoint
,即 http://localhost:8080/auth/realms/demo/protocol/openid-connect/logout
。
- 用户被重定向到 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'
所以,看起来网关成功地使会话无效了。但这里有两个我不明白的观察结果:
- Keycloak 似乎仍有该用户的会话。 Keycloak cookie 仍然存在,重新加载 SPA 会直接基于这些 cookie 创建一个新会话。
- POST 到
/logout
重定向到 /login?logout
被 401 拒绝。我的理解是,重定向应该发生在 FRONTEND_URL = "http://localhost:8093"
?
也许您正在做 ajax post 而不是表格 post?服务器无法在 ajax 调用时将您的浏览器重定向到 keycloak。
我是 运行 一个 Spring 云网关,它使用 Keycloak 处理 OAuth2 身份验证。单页应用程序 (SPA) 的登录部分工作正常,但现在我无法注销。
这是我的想法:
- SPA 在网关上将 POST 发送到
/logout
。 - 网关使会话及其
SESSION
cookie 无效。 - 网关联系 Keycloak 的
end_session_endpoint
,即http://localhost:8080/auth/realms/demo/protocol/openid-connect/logout
。 - 用户被重定向到 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'
所以,看起来网关成功地使会话无效了。但这里有两个我不明白的观察结果:
- Keycloak 似乎仍有该用户的会话。 Keycloak cookie 仍然存在,重新加载 SPA 会直接基于这些 cookie 创建一个新会话。
- POST 到
/logout
重定向到/login?logout
被 401 拒绝。我的理解是,重定向应该发生在FRONTEND_URL = "http://localhost:8093"
?
也许您正在做 ajax post 而不是表格 post?服务器无法在 ajax 调用时将您的浏览器重定向到 keycloak。