diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinWebSecurity.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinWebSecurity.java index a80ed7c334e..40da61f57f0 100644 --- a/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinWebSecurity.java +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinWebSecurity.java @@ -43,6 +43,8 @@ import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.SecurityFilterChain; @@ -53,7 +55,7 @@ import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.security.web.csrf.CsrfException; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -499,6 +501,10 @@ protected void setLoginView(HttpSecurity http, /** * Sets up the login page URI of the OAuth2 provider on the specified * HttpSecurity instance. + *

+ *

+ * This method also configures a logout success handler that redirects to + * the application base URL after logout. * * @param http * the http security from {@link #filterChain(HttpSecurity)} @@ -511,10 +517,85 @@ protected void setLoginView(HttpSecurity http, */ protected void setOAuth2LoginPage(HttpSecurity http, String oauth2LoginPage) throws Exception { + setOAuth2LoginPage(http, oauth2LoginPage, "{baseUrl}"); + } + + /** + * Sets up the login page URI of the OAuth2 provider and the post logout URI + * on the specified HttpSecurity instance. + *

+ *

+ * The post logout redirect uri can be relative or absolute URI or a + * template. The supported uri template variables are: {baseScheme}, + * {baseHost}, {basePort} and {basePath}. + *

+ *

+ * NOTE: "{baseUrl}" is also supported, which is the same as + * "{baseScheme}://{baseHost}{basePort}{basePath}" handler. + * setPostLogoutRedirectUri("{baseUrl}"); + * + * @param http + * the http security from {@link #filterChain(HttpSecurity)} + * @param oauth2LoginPage + * the login page of the OAuth2 provider. This Specifies the URL + * to send users to if login is required. + * @param postLogoutRedirectUri + * the post logout redirect uri. Can be a template. + * @throws Exception + * Re-throws the possible exceptions while activating + * OAuth2LoginConfigurer + */ + protected void setOAuth2LoginPage(HttpSecurity http, String oauth2LoginPage, + String postLogoutRedirectUri) throws Exception { http.oauth2Login(cfg -> cfg.loginPage(oauth2LoginPage).successHandler( getVaadinSavedRequestAwareAuthenticationSuccessHandler(http)) .permitAll()); accessControl.setLoginView(servletContextPath + oauth2LoginPage); + if (postLogoutRedirectUri != null) { + applicationContext + .getBeanProvider(ClientRegistrationRepository.class) + .getIfAvailable(); + var logoutSuccessHandler = oidcLogoutSuccessHandler( + postLogoutRedirectUri); + if (logoutSuccessHandler != null) { + http.logout( + cfg -> cfg.logoutSuccessHandler(logoutSuccessHandler)); + } + } + } + + /** + * Gets a {@code OidcClientInitiatedLogoutSuccessHandler} instance that + * redirects to the given URL after logout. + *

+ *

+ * If a {@code ClientRegistrationRepository} bean is not registered in the + * application context, the method returns {@literal null}. + * + * @param postLogoutRedirectUri + * the post logout redirect uri + * @return a {@code OidcClientInitiatedLogoutSuccessHandler}, or + * {@literal null} if a {@code ClientRegistrationRepository} bean is + * not registered in the application context. + */ + // Using base interface as return type to avoid potential + // ClassNotFoundException when Spring Boot introspect configuration class + // during startup, if spring-security-oauth2-client is not on classpath + protected LogoutSuccessHandler oidcLogoutSuccessHandler( + String postLogoutRedirectUri) { + var clientRegistrationRepository = applicationContext + .getBeanProvider(ClientRegistrationRepository.class) + .getIfAvailable(); + if (clientRegistrationRepository != null) { + var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler( + clientRegistrationRepository); + logoutHandler.setRedirectStrategy(new UidlRedirectStrategy()); + logoutHandler.setPostLogoutRedirectUri(postLogoutRedirectUri); + return logoutHandler; + } + LoggerFactory.getLogger(VaadinWebSecurity.class).warn( + "Cannot create OidcClientInitiatedLogoutSuccessHandler because ClientRegistrationRepository bean is not available."); + return null; } /** diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinWebSecurityTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinWebSecurityTest.java index 15b19397897..4ed6759348f 100644 --- a/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinWebSecurityTest.java +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinWebSecurityTest.java @@ -20,12 +20,15 @@ import jakarta.servlet.http.HttpServletResponse; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.security.config.ObjectPostProcessor; @@ -33,16 +36,21 @@ import org.springframework.security.config.annotation.configuration.ObjectPostProcessorConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; import org.springframework.security.core.Authentication; -import com.vaadin.flow.spring.security.AuthenticationContext.CompositeLogoutHandler; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.util.ReflectionTestUtils; import com.vaadin.flow.server.auth.NavigationAccessControl; +import com.vaadin.flow.spring.security.AuthenticationContext.CompositeLogoutHandler; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @RunWith(SpringRunner.class) @@ -83,12 +91,7 @@ public void navigationAccessControl_enabledByDefault() throws Exception { Map.of(ApplicationContext.class, appCtx)); VaadinWebSecurity testConfig = new VaadinWebSecurity() { }; - NavigationAccessControl accessControl = new NavigationAccessControl(); - ReflectionTestUtils.setField(testConfig, "accessControl", - accessControl); - RequestUtil requestUtil = mock(RequestUtil.class); - Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*"); - ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil); + mockVaadinWebSecurityInjection(testConfig); testConfig.filterChain(httpSecurity); Assert.assertTrue( @@ -108,12 +111,7 @@ protected boolean enableNavigationAccessControl() { return false; } }; - NavigationAccessControl accessControl = new NavigationAccessControl(); - ReflectionTestUtils.setField(testConfig, "accessControl", - accessControl); - RequestUtil requestUtil = mock(RequestUtil.class); - Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*"); - ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil); + mockVaadinWebSecurityInjection(testConfig); testConfig.filterChain(httpSecurity); Assert.assertFalse( @@ -121,6 +119,93 @@ protected boolean enableNavigationAccessControl() { testConfig.getNavigationAccessControl().isEnabled()); } + @Test + public void filterChain_oauth2login_configuresLoginPageAndLogoutHandler() + throws Exception { + assertOauth2Configuration(null); + assertOauth2Configuration("/session-ended"); + } + + private void assertOauth2Configuration(String postLogoutUri) + throws Exception { + String expectedLogoutUri = postLogoutUri != null ? postLogoutUri + : "{baseUrl}"; + HttpSecurity httpSecurity = new HttpSecurity(postProcessor, + new AuthenticationManagerBuilder(postProcessor), + Map.of(ApplicationContext.class, appCtx)); + AtomicReference postLogoutUriHolder = new AtomicReference<>( + "NOT SET"); + VaadinWebSecurity testConfig = new VaadinWebSecurity() { + @Override + protected void configure(HttpSecurity http) throws Exception { + super.configure(http); + if (postLogoutUri != null) { + setOAuth2LoginPage(http, "/externalLogin", postLogoutUri); + } else { + setOAuth2LoginPage(http, "/externalLogin"); + } + } + + @Override + protected LogoutSuccessHandler oidcLogoutSuccessHandler( + String postLogoutRedirectUri) { + postLogoutUriHolder.set(postLogoutRedirectUri); + return super.oidcLogoutSuccessHandler(postLogoutRedirectUri); + } + }; + TestNavigationAccessControl accessControl = mockVaadinWebSecurityInjection( + testConfig); + ClientRegistrationRepository repository = mock( + ClientRegistrationRepository.class); + ObjectProvider provider = new ObjectProvider() { + @Override + public ClientRegistrationRepository getObject() + throws BeansException { + return repository; + } + }; + ApplicationContext appCtx = Mockito.mock(ApplicationContext.class); + Mockito.when(appCtx.getBeanProvider(ClientRegistrationRepository.class)) + .thenReturn(provider); + ReflectionTestUtils.setField(testConfig, "applicationContext", appCtx); + httpSecurity.setSharedObject(ClientRegistrationRepository.class, + repository); + + testConfig.filterChain(httpSecurity); + + Assert.assertEquals("/externalLogin", accessControl.getLoginUrl()); + LogoutSuccessHandler logoutSuccessHandler = httpSecurity + .getConfigurer(LogoutConfigurer.class) + .getLogoutSuccessHandler(); + Assert.assertNotNull("Expected logout success handler to be configured", + logoutSuccessHandler); + Assert.assertTrue( + "Expected logout success handler to be of type OidcClientInitiatedLogoutSuccessHandler, but was " + + logoutSuccessHandler.getClass().getName(), + logoutSuccessHandler instanceof OidcClientInitiatedLogoutSuccessHandler); + Assert.assertEquals("Unexpected post logout uri", expectedLogoutUri, + postLogoutUriHolder.get()); + } + + private static TestNavigationAccessControl mockVaadinWebSecurityInjection( + VaadinWebSecurity testConfig) { + TestNavigationAccessControl accessControl = new TestNavigationAccessControl(); + ReflectionTestUtils.setField(testConfig, "accessControl", + accessControl); + RequestUtil requestUtil = mock(RequestUtil.class); + Mockito.when(requestUtil.getUrlMapping()).thenReturn("/*"); + Mockito.when(requestUtil.applyUrlMapping(anyString())).then(i -> { + String path = i.getArgument(0, String.class); + if (!path.startsWith("/")) { + path = "/" + path; + } + return path; + }); + ReflectionTestUtils.setField(testConfig, "requestUtil", requestUtil); + ReflectionTestUtils.setField(testConfig, "servletContextPath", ""); + return accessControl; + } + static class TestConfig extends VaadinWebSecurity { LogoutHandler handler1 = mock(LogoutHandler.class); LogoutHandler handler2 = mock(LogoutHandler.class); @@ -144,4 +229,12 @@ protected void addLogoutHandlers(Consumer registry) { } } + static class TestNavigationAccessControl extends NavigationAccessControl { + + @Override + protected String getLoginUrl() { + return super.getLoginUrl(); + } + } + }