Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for OAuth2 logout configuration #20820

Merged
merged 4 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -499,6 +501,10 @@ protected void setLoginView(HttpSecurity http,
/**
* Sets up the login page URI of the OAuth2 provider on the specified
* HttpSecurity instance.
* <p>
* </p>
* 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)}
Expand All @@ -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.
* <p>
* </p>
* 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}.
* <p>
* </p>
* 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.
* <p>
* </p>
* 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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,37 @@
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;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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)
Expand Down Expand Up @@ -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(
Expand All @@ -108,19 +111,101 @@ 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(
"Expecting navigation access control to be disable by VaadinWebSecurity subclass",
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<String> 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<ClientRegistrationRepository> provider = new ObjectProvider<ClientRegistrationRepository>() {
@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);
Expand All @@ -144,4 +229,12 @@ protected void addLogoutHandlers(Consumer<LogoutHandler> registry) {
}
}

static class TestNavigationAccessControl extends NavigationAccessControl {

@Override
protected String getLoginUrl() {
return super.getLoginUrl();
}
}

}
Loading