Quellcode durchsuchen

Support for OIDC RP-Initiated Logout

Fixes: gh-5350
Josh Cummings vor 7 Jahren
Ursprung
Commit
248a8c030b

+ 67 - 8
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java

@@ -15,10 +15,20 @@
  */
 package org.springframework.security.config.annotation.web.configurers.oauth2.client;
 
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
 import org.apache.http.HttpHeaders;
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+
 import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.ApplicationListener;
@@ -35,17 +45,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
+import org.springframework.security.config.test.SpringTestRule;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
 import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
 import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
 import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
+import org.springframework.security.oauth2.client.web.oidc.logout.OidcClientInitiatedLogoutSuccessHandler;
 import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
 import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
 import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
 import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
@@ -61,6 +76,7 @@ import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames
 import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
 import org.springframework.security.oauth2.core.oidc.user.OidcUser;
 import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
+import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers;
 import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
 import org.springframework.security.oauth2.core.user.OAuth2User;
 import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
@@ -71,21 +87,18 @@ import org.springframework.security.web.FilterChainProxy;
 import org.springframework.security.web.context.HttpRequestResponseHolder;
 import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
 import org.springframework.security.web.context.SecurityContextRepository;
+import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
 
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
 
 /**
  * Tests for {@link OAuth2LoginConfigurer}.
@@ -115,6 +128,12 @@ public class OAuth2LoginConfigurerTests {
 	@Autowired
 	SecurityContextRepository securityContextRepository;
 
+	@Rule
+	public final SpringTestRule spring = new SpringTestRule();
+
+	@Autowired(required = false)
+	MockMvc mvc;
+
 	private MockHttpServletRequest request;
 	private MockHttpServletResponse response;
 	private MockFilterChain filterChain;
@@ -455,6 +474,21 @@ public class OAuth2LoginConfigurerTests {
 						"available: expected single matching bean but found 2: jwtDecoderFactory1,jwtDecoderFactory2");
 	}
 
+	@Test
+	public void logoutWhenUsingOidcLogoutHandlerThenRedirects() throws Exception {
+		this.spring.register(OAuth2LoginConfigWithOidcLogoutSuccessHandler.class).autowire();
+
+		OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(
+				TestOidcUsers.create(),
+				AuthorityUtils.NO_AUTHORITIES,
+				"registration-id");
+
+		this.mvc.perform(post("/logout")
+				.with(authentication(token))
+				.with(csrf()))
+				.andExpect(redirectedUrl("http://logout?id_token_hint=id-token"));
+	}
+
 	private void loadConfig(Class<?>... configs) {
 		AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
 		applicationContext.register(configs);
@@ -591,6 +625,31 @@ public class OAuth2LoginConfigurerTests {
 		}
 	}
 
+	@EnableWebSecurity
+	static class OAuth2LoginConfigWithOidcLogoutSuccessHandler extends CommonWebSecurityConfigurerAdapter {
+		@Override
+		protected void configure(HttpSecurity http) throws Exception {
+			http
+				.logout()
+					.logoutSuccessHandler(oidcLogoutSuccessHandler());
+			super.configure(http);
+		}
+
+		@Bean
+		OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {
+			return new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository());
+		}
+
+		@Bean
+		ClientRegistrationRepository clientRegistrationRepository() {
+			Map<String, Object> providerMetadata =
+					Collections.singletonMap("end_session_endpoint", "http://logout");
+			return new InMemoryClientRegistrationRepository(
+					TestClientRegistrations.clientRegistration()
+							.providerConfigurationMetadata(providerMetadata).build());
+		}
+	}
+
 	private static abstract class CommonWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
 		@Override
 		protected void configure(HttpSecurity http) throws Exception {

+ 98 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandler.java

@@ -0,0 +1,98 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.client.web.oidc.logout;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
+import org.springframework.util.Assert;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * A logout success handler for initiating OIDC logout through the user agent.
+ *
+ * @author Josh Cummings
+ * @since 5.2
+ * @see <a href="http://openid.net/specs/openid-connect-session-1_0.html#RPLogout">RP-Initiated Logout</a>
+ * @see org.springframework.security.web.authentication.logout.LogoutSuccessHandler
+ */
+public final class OidcClientInitiatedLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
+	private final ClientRegistrationRepository clientRegistrationRepository;
+
+	private URI postLogoutRedirectUri;
+
+	public OidcClientInitiatedLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) {
+		Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
+		this.clientRegistrationRepository = clientRegistrationRepository;
+	}
+
+	@Override
+	protected String determineTargetUrl(HttpServletRequest request,
+			HttpServletResponse response, Authentication authentication) {
+
+		return Optional.of(authentication)
+				.filter(OAuth2AuthenticationToken.class::isInstance)
+				.filter(token -> authentication.getPrincipal() instanceof OidcUser)
+				.map(OAuth2AuthenticationToken.class::cast)
+				.flatMap(this::endSessionEndpoint)
+				.map(endSessionEndpoint -> endpointUri(endSessionEndpoint, authentication))
+				.orElseGet(() -> super.determineTargetUrl(request, response));
+	}
+
+	private Optional<URI> endSessionEndpoint(OAuth2AuthenticationToken token) {
+		String registrationId = token.getAuthorizedClientRegistrationId();
+		return Optional.of(
+				this.clientRegistrationRepository.findByRegistrationId(registrationId))
+				.map(ClientRegistration::getProviderDetails)
+				.map(ClientRegistration.ProviderDetails::getConfigurationMetadata)
+				.map(configurationMetadata -> configurationMetadata.get("end_session_endpoint"))
+				.map(Object::toString)
+				.map(URI::create);
+	}
+
+	private String endpointUri(URI endSessionEndpoint, Authentication authentication) {
+		UriComponentsBuilder builder = UriComponentsBuilder.fromUri(endSessionEndpoint);
+		builder.queryParam("id_token_hint", idToken(authentication));
+		if (this.postLogoutRedirectUri != null) {
+			builder.queryParam("post_logout_redirect_uri", this.postLogoutRedirectUri);
+		}
+		return builder.encode(StandardCharsets.UTF_8).build().toUriString();
+	}
+
+	private String idToken(Authentication authentication) {
+		return ((OidcUser) authentication.getPrincipal()).getIdToken().getTokenValue();
+	}
+
+	/**
+	 * Set the post logout redirect uri to use
+	 *
+	 * @param postLogoutRedirectUri - A valid URL to which the OP should redirect after logging out the user
+	 */
+	public void setPostLogoutRedirectUri(URI postLogoutRedirectUri) {
+		Assert.notNull(postLogoutRedirectUri, "postLogoutRedirectUri cannot be null");
+		this.postLogoutRedirectUri = postLogoutRedirectUri;
+	}
+}

+ 37 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/authentication/TestOAuth2AuthenticationTokens.java

@@ -0,0 +1,37 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.client.authentication;
+
+import java.util.Collection;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.security.oauth2.core.user.TestOAuth2Users;
+
+/**
+ * @author Josh Cummings
+ * @since 5.2
+ */
+public class TestOAuth2AuthenticationTokens {
+	public static OAuth2AuthenticationToken authenticated(String... roles) {
+		OAuth2User principal = TestOAuth2Users.create();
+		Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(roles);
+		String registrationId = "registration-id";
+		return new OAuth2AuthenticationToken(principal, authorities, registrationId);
+	}
+}

+ 153 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/oidc/logout/OidcClientInitiatedLogoutSuccessHandlerTests.java

@@ -0,0 +1,153 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.client.web.oidc.logout;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Collections;
+import javax.servlet.ServletException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
+import org.springframework.security.oauth2.core.oidc.user.TestOidcUsers;
+import org.springframework.security.oauth2.core.user.TestOAuth2Users;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OidcClientInitiatedLogoutSuccessHandler}
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class OidcClientInitiatedLogoutSuccessHandlerTests {
+	ClientRegistration registration = TestClientRegistrations
+			.clientRegistration()
+			.providerConfigurationMetadata(
+					Collections.singletonMap("end_session_endpoint", "http://endpoint"))
+			.build();
+	ClientRegistrationRepository repository = new InMemoryClientRegistrationRepository(registration);
+
+	MockHttpServletRequest request;
+	MockHttpServletResponse response;
+
+	OidcClientInitiatedLogoutSuccessHandler handler;
+
+	@Before
+	public void setup() {
+		this.request = new MockHttpServletRequest();
+		this.response = new MockHttpServletResponse();
+		this.handler = new OidcClientInitiatedLogoutSuccessHandler(this.repository);
+	}
+
+	@Test
+	public void logoutWhenOidcRedirectUrlConfiguredThenRedirects()
+			throws IOException, ServletException {
+		OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(
+				TestOidcUsers.create(),
+				AuthorityUtils.NO_AUTHORITIES,
+				this.registration.getRegistrationId());
+
+		this.request.setUserPrincipal(token);
+		this.handler.onLogoutSuccess(this.request, this.response, token);
+
+		assertThat(this.response.getRedirectedUrl()).isEqualTo("http://endpoint?id_token_hint=id-token");
+	}
+
+	@Test
+	public void logoutWhenNotOAuth2AuthenticationThenDefaults()
+			throws IOException, ServletException {
+		Authentication token = mock(Authentication.class);
+
+		this.request.setUserPrincipal(token);
+		this.handler.setDefaultTargetUrl("http://default");
+		this.handler.onLogoutSuccess(this.request, this.response, token);
+
+		assertThat(this.response.getRedirectedUrl()).isEqualTo("http://default");
+	}
+
+	@Test
+	public void logoutWhenNotOidcUserThenDefaults()
+			throws IOException, ServletException {
+		OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(
+				TestOAuth2Users.create(),
+				AuthorityUtils.NO_AUTHORITIES,
+				this.registration.getRegistrationId());
+
+		this.request.setUserPrincipal(token);
+		this.handler.setDefaultTargetUrl("http://default");
+		this.handler.onLogoutSuccess(this.request, this.response, token);
+
+		assertThat(this.response.getRedirectedUrl()).isEqualTo("http://default");
+	}
+
+	@Test
+	public void logoutWhenClientRegistrationHasNoEndSessionEndpointThenDefaults()
+			throws Exception {
+
+		ClientRegistration registration = TestClientRegistrations.clientRegistration().build();
+		ClientRegistrationRepository repository = new InMemoryClientRegistrationRepository(registration);
+		OidcClientInitiatedLogoutSuccessHandler handler = new OidcClientInitiatedLogoutSuccessHandler(repository);
+
+		OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(
+				TestOidcUsers.create(),
+				AuthorityUtils.NO_AUTHORITIES,
+				registration.getRegistrationId());
+
+		this.request.setUserPrincipal(token);
+		handler.setDefaultTargetUrl("http://default");
+		handler.onLogoutSuccess(this.request, this.response, token);
+
+		assertThat(this.response.getRedirectedUrl()).isEqualTo("http://default");
+	}
+
+	@Test
+	public void logoutWhenUsingPostLogoutRedirectUriThenIncludesItInRedirect()
+			throws IOException, ServletException {
+
+		OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(
+				TestOidcUsers.create(),
+				AuthorityUtils.NO_AUTHORITIES,
+				this.registration.getRegistrationId());
+
+		this.handler.setPostLogoutRedirectUri(URI.create("http://postlogout?encodedparam=value"));
+		this.request.setUserPrincipal(token);
+		this.handler.onLogoutSuccess(this.request, this.response, token);
+
+		assertThat(this.response.getRedirectedUrl()).isEqualTo("http://endpoint?" +
+				"id_token_hint=id-token&" +
+				"post_logout_redirect_uri=http://postlogout?encodedparam%3Dvalue");
+	}
+
+	@Test
+	public void setPostLogoutRedirectUriWhenGivenNullThenThrowsException() {
+		assertThatThrownBy(() -> this.handler.setPostLogoutRedirectUri(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+}

+ 12 - 2
web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationTargetUrlRequestHandler.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2016 the original author or authors.
+ * Copyright 2002-2019 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -80,7 +80,7 @@ public abstract class AbstractAuthenticationTargetUrlRequestHandler {
 	 */
 	protected void handle(HttpServletRequest request, HttpServletResponse response,
 			Authentication authentication) throws IOException, ServletException {
-		String targetUrl = determineTargetUrl(request, response);
+		String targetUrl = determineTargetUrl(request, response, authentication);
 
 		if (response.isCommitted()) {
 			logger.debug("Response has already been committed. Unable to redirect to "
@@ -91,6 +91,16 @@ public abstract class AbstractAuthenticationTargetUrlRequestHandler {
 		redirectStrategy.sendRedirect(request, response, targetUrl);
 	}
 
+	/**
+	 * Builds the target URL according to the logic defined in the main class Javadoc
+	 *
+	 * @since 5.2
+	 */
+	protected String determineTargetUrl(HttpServletRequest request,
+			HttpServletResponse response, Authentication authentication) {
+		return determineTargetUrl(request, response);
+	}
+
 	/**
 	 * Builds the target URL according to the logic defined in the main class Javadoc.
 	 */