diff --git a/scripts/cargo/uaa.yml b/scripts/cargo/uaa.yml index bbbcd7c08b4..4a029b0bd1a 100644 --- a/scripts/cargo/uaa.yml +++ b/scripts/cargo/uaa.yml @@ -56,6 +56,7 @@ jwt: - excluded-claim1 - excluded-claim2 login: + zidHeaderEnabled: true saml: activeKeyId: key1 keys: diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index 3ba72421ee0..819a330f131 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -17,6 +17,7 @@ import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.web.HeaderFilter; import org.cloudfoundry.identity.uaa.web.LimitedModeUaaFilter; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneMismatchCheckFilter; import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; import org.cloudfoundry.identity.uaa.zone.IdentityZoneResolvingFilter; import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; @@ -169,8 +170,11 @@ FilterRegistrationBean userManagementFilter } @Bean - FilterRegistrationBean identityZoneResolvingFilter(IdentityZoneProvisioning provisioning) { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(provisioning); + FilterRegistrationBean identityZoneResolvingFilter( + final IdentityZoneProvisioning provisioning, + @Qualifier("zidHeaderEnabled") final boolean zidHeaderEnabled + ) { + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(provisioning, zidHeaderEnabled); filter.setDefaultInternalHostnames(new HashSet<>(Arrays.asList( UaaUrlUtils.getHostForURI(uaaProps.url()), UaaUrlUtils.getHostForURI(loginProps.url()), @@ -182,13 +186,26 @@ FilterRegistrationBean identityZoneResolvingFilter( return bean; } + @Bean + FilterRegistrationBean identityZoneMismatchCheckFilter( + final IdentityZoneManager identityZoneManager + ) { + final IdentityZoneMismatchCheckFilter filter = new IdentityZoneMismatchCheckFilter( + identityZoneManager, + new DefaultRedirectStrategy(), + "/login" + ); + final FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.setEnabled(false); + return bean; + } + @Bean FilterRegistrationBean sessionResetFilter( @Qualifier("userDatabase") JdbcUaaUserDatabase userDatabase ) { SessionResetFilter filter = new SessionResetFilter( new DefaultRedirectStrategy(), - identityZoneManager, "/login", userDatabase ); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java index 92c76c3bf9a..07bceb6098a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java @@ -17,6 +17,7 @@ import org.cloudfoundry.identity.uaa.web.HeaderFilter; import org.cloudfoundry.identity.uaa.web.LimitedModeUaaFilter; import org.cloudfoundry.identity.uaa.web.UaaFilterChain; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneMismatchCheckFilter; import org.cloudfoundry.identity.uaa.zone.IdentityZoneResolvingFilter; import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; import org.springframework.beans.factory.annotation.Qualifier; @@ -126,6 +127,7 @@ SecurityFilterChainPostProcessor securityFilterChainPostProcessor( @Qualifier("disableIdTokenResponseFilter") FilterRegistrationBean disableIdTokenResponseFilter, @Qualifier("saml2WebSsoAuthenticationRequestFilter") FilterRegistrationBean saml2WebSsoAuthenticationRequestFilter, @Qualifier("saml2WebSsoAuthenticationFilter") FilterRegistrationBean saml2WebSsoAuthenticationFilter, + @Qualifier("identityZoneMismatchCheckFilter") FilterRegistrationBean identityZoneMismatchCheckFilter, @Qualifier("identityZoneSwitchingFilter") FilterRegistrationBean identityZoneSwitchingFilter, @Qualifier("saml2LogoutRequestFilter") FilterRegistrationBean saml2LogoutRequestFilter, @Qualifier("saml2LogoutResponseFilter") FilterRegistrationBean saml2LogoutResponseFilter, @@ -167,7 +169,8 @@ SecurityFilterChainPostProcessor securityFilterChainPostProcessor( additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.position(filterPos++), disableIdTokenResponseFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.position(filterPos++), saml2WebSsoAuthenticationRequestFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.position(filterPos++), saml2WebSsoAuthenticationFilter.getFilter()); - additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(OAuth2AuthenticationProcessingFilter.class), identityZoneSwitchingFilter.getFilter()); + additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(OAuth2AuthenticationProcessingFilter.class), identityZoneMismatchCheckFilter.getFilter()); + additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(IdentityZoneMismatchCheckFilter.class), identityZoneSwitchingFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(IdentityZoneSwitchingFilter.class), saml2LogoutRequestFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(Saml2LogoutRequestFilter.class), saml2LogoutResponseFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(Saml2LogoutResponseFilter.class), userManagementSecurityFilter.getFilter()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilter.java index 9d5cbca6338..ba09dfe7f03 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilter.java @@ -19,7 +19,6 @@ import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; import org.cloudfoundry.identity.uaa.user.UaaUserPrototype; -import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.context.SecurityContext; @@ -35,21 +34,18 @@ import javax.servlet.http.HttpSession; import java.io.IOException; import java.util.Date; -import java.util.Objects; public class SessionResetFilter extends OncePerRequestFilter { private static final Logger logger = LoggerFactory.getLogger(SessionResetFilter.class); private final RedirectStrategy strategy; - private final IdentityZoneManager identityZoneManager; @Getter private final String redirectUrl; private final UaaUserDatabase userDatabase; - public SessionResetFilter(RedirectStrategy strategy, IdentityZoneManager identityZoneManager, String redirectUrl, UaaUserDatabase userDatabase) { + public SessionResetFilter(RedirectStrategy strategy, String redirectUrl, UaaUserDatabase userDatabase) { this.strategy = strategy; - this.identityZoneManager = identityZoneManager; this.redirectUrl = redirectUrl; this.userDatabase = userDatabase; } @@ -58,12 +54,6 @@ public SessionResetFilter(RedirectStrategy strategy, IdentityZoneManager identit protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null && context.getAuthentication() instanceof UaaAuthentication authentication) { - // zone check - if (!Objects.equals(identityZoneManager.getCurrentIdentityZoneId(), authentication.getPrincipal().getZoneId())) { - handleRedirect(request, response); - return; - } - // is authenticated UAA user if (authentication.isAuthenticated() && OriginKeys.UAA.equals(authentication.getPrincipal().getOrigin()) && diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneMismatchCheckFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneMismatchCheckFilter.java new file mode 100644 index 00000000000..cec814869dd --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneMismatchCheckFilter.java @@ -0,0 +1,106 @@ +package org.cloudfoundry.identity.uaa.zone; + +import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; +import org.cloudfoundry.identity.uaa.oauth.UaaOauth2Authentication; +import org.cloudfoundry.identity.uaa.oauth.provider.authentication.OAuth2AuthenticationProcessingFilter; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.context.SecurityContextPersistenceFilter; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +/** + * Checks whether there is a mismatch between ... + *
    + *
  • the identity zone in the {@link IdentityZoneHolder} (specified by the subdomain or "X-Zid" header) and
  • + *
  • the identity zone in the {@link SecurityContext} (the one set in the session or the token).
  • + *
+ * These two pieces of information being necessary also implies the position of the filter in the chain: + *
    + *
  • after {@link IdentityZoneResolvingFilter}, which sets the identity zone in the IdentityZoneHolder and
  • + *
  • after {@link SecurityContextPersistenceFilter}, which sets the SecurityContext from the session
  • + *
  • after {@link OAuth2AuthenticationProcessingFilter}, which sets the SecurityContext from the token passed in the request
  • + *
+ * Additionally, the filter must be placed before the {@link IdentityZoneSwitchingFilter}. + */ +public class IdentityZoneMismatchCheckFilter extends OncePerRequestFilter { + + private final IdentityZoneManager identityZoneManager; + private final RedirectStrategy redirectStrategy; + private final String redirectUrl; + + public IdentityZoneMismatchCheckFilter( + final IdentityZoneManager identityZoneManager, + final RedirectStrategy redirectStrategy, + final String redirectUrl + ) { + this.identityZoneManager = identityZoneManager; + this.redirectStrategy = redirectStrategy; + this.redirectUrl = redirectUrl; + } + + @Override + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain + ) throws ServletException, IOException { + final Optional authenticationOpt = Optional.ofNullable(SecurityContextHolder.getContext()) + .map(SecurityContext::getAuthentication); + + if (authenticationOpt.isEmpty()) { + // not yet authenticated -> continue + filterChain.doFilter(request, response); + return; + } + + final Authentication authentication = authenticationOpt.get(); + + final String zoneIdFromSessionOrToken; + if (authentication instanceof UaaAuthentication uaaAuthentication) { + // authenticated via session + zoneIdFromSessionOrToken = uaaAuthentication.getPrincipal().getZoneId(); + } else if (authentication instanceof UaaOauth2Authentication uaaOauth2Authentication) { + /* authenticated via OAuth2 token + * IMPORTANT: already addressed by the issuer check in OAuth2AuthenticationProcessingFilter + * -> requires zone-specific subdomain to be set in the 'iss' claim of the token */ + zoneIdFromSessionOrToken = uaaOauth2Authentication.getZoneId(); + } else { + // no zone information in authentication + filterChain.doFilter(request, response); + return; + } + + // redirect to login page if the zones do not match + if (!Objects.equals(zoneIdFromSessionOrToken, identityZoneManager.getCurrentIdentityZoneId())) { + handleRedirect(request, response); + return; + } + + filterChain.doFilter(request, response); + } + + protected void handleRedirect( + final HttpServletRequest request, + final HttpServletResponse response + ) throws IOException { + // if a session was present, invalidate it + final HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + + redirectStrategy.sendRedirect(request, response, redirectUrl); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java index 2440a1859e4..43ddc2628b9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java @@ -37,45 +37,67 @@ */ public class IdentityZoneResolvingFilter extends OncePerRequestFilter implements InitializingBean { + /** + * Header for specifying the identity zone in which the request should be performed. If both a subdomain and the + * header are defined, the header takes precedence. + */ + private static final String X_ZID_HEADER = "X-zid"; + + private final boolean zidHeaderEnabled; + private final IdentityZoneProvisioning dao; private final Set staticResources = Set.of("/resources/", "/vendor/font-awesome/"); private final Set defaultZoneHostnames = new HashSet<>(); private final Logger logger = LoggerFactory.getLogger(getClass()); - public IdentityZoneResolvingFilter(final IdentityZoneProvisioning dao) { + public IdentityZoneResolvingFilter(final IdentityZoneProvisioning dao, final boolean zidHeaderEnabled) { this.dao = dao; + this.zidHeaderEnabled = zidHeaderEnabled; } @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain + ) throws ServletException, IOException { + final String zidFromHeader = request.getHeader(X_ZID_HEADER); + final String hostname = request.getServerName(); + final String subdomain = getSubdomain(hostname); + IdentityZone identityZone = null; - String hostname = request.getServerName(); - String subdomain = getSubdomain(hostname); - if (subdomain != null) { - try { + String zoneResolvingDescription = null; // for logging and error messages + try { + if (zidHeaderEnabled && zidFromHeader != null) { + zoneResolvingDescription = "zid '%s'".formatted(zidFromHeader); + identityZone = dao.retrieve(zidFromHeader); + } else { + zoneResolvingDescription = "subdomain '%s'".formatted(subdomain); identityZone = dao.retrieveBySubdomain(subdomain); - } catch (EmptyResultDataAccessException ex) { - logger.debug("Cannot find identity zone for subdomain {}", subdomain); - } catch (Exception ex) { - String message = "Internal server error while fetching identity zone for subdomain" + subdomain; - logger.warn(message, ex); - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message); - return; } + } catch (final EmptyResultDataAccessException | ZoneDoesNotExistsException ex) { + logger.debug("Cannot find identity zone for {}", zoneResolvingDescription); + } catch (final Exception ex) { + final String message = "Internal server error while fetching identity zone for %s" + .formatted(zoneResolvingDescription); + logger.warn(message, ex); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message); + return; } + if (identityZone == null) { // skip filter to static resources in order to serve images and css in case of invalid zones - boolean isStaticResource = staticResources.stream().anyMatch(UaaUrlUtils.getRequestPath(request)::startsWith); + final boolean isStaticResource = staticResources.stream().anyMatch(UaaUrlUtils.getRequestPath(request)::startsWith); if (isStaticResource) { filterChain.doFilter(request, response); return; } request.setAttribute("error_message_code", "zone.not.found"); - response.sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find identity zone for subdomain " + subdomain); + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find identity zone for %s".formatted(zoneResolvingDescription)); return; } + try { IdentityZoneHolder.set(identityZone); filterChain.doFilter(request, response); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/beans/IdentityZoneResolvingConfig.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/beans/IdentityZoneResolvingConfig.java new file mode 100644 index 00000000000..4ae8260b021 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/beans/IdentityZoneResolvingConfig.java @@ -0,0 +1,17 @@ +package org.cloudfoundry.identity.uaa.zone.beans; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class IdentityZoneResolvingConfig { + + @Bean + @Qualifier("zidHeaderEnabled") + public boolean zidHeaderEnabled(@Value("${login.zidHeaderEnabled:false}") final boolean enabled) { + return enabled; + } + +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilterTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilterTests.java index 6b4f2059dcd..50ac729707e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilterTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/SessionResetFilterTests.java @@ -21,7 +21,6 @@ import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; -import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -79,7 +78,7 @@ void setUpFilter() { response = mock(HttpServletResponse.class); session = mock(HttpSession.class); when(request.getSession(anyBoolean())).thenReturn(session); - filter = new SessionResetFilter(new DefaultRedirectStrategy(), new IdentityZoneManagerImpl(),"/login", userDatabase); + filter = new SessionResetFilter(new DefaultRedirectStrategy(), "/login", userDatabase); } private void addUsersToInMemoryDb() { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneMismatchCheckFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneMismatchCheckFilterTest.java new file mode 100644 index 00000000000..c63238088e0 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneMismatchCheckFilterTest.java @@ -0,0 +1,163 @@ +package org.cloudfoundry.identity.uaa.zone; + +import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; +import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.oauth.UaaOauth2Authentication; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.RedirectStrategy; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; + +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IdentityZoneMismatchCheckFilterTest { + + private static final String REDIRECT_URL = "/login"; + private static final String ZONE1_ID = "zone1"; + private static final String ZONE2_ID = "zone2"; + + @Mock + private IdentityZoneManager identityZoneManager; + + @Mock + private RedirectStrategy redirectStrategy; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpSession session; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + private final MockedStatic securityContextHolderMockedStatic = mockStatic(SecurityContextHolder.class); + + private IdentityZoneMismatchCheckFilter filter; + + @BeforeEach + void setUp() { + // return mock session to check if it is invalidated when necessary + lenient().when(request.getSession(false)).thenReturn(session); + + filter = new IdentityZoneMismatchCheckFilter(identityZoneManager, redirectStrategy, REDIRECT_URL); + } + + @AfterEach + void tearDown() { + securityContextHolderMockedStatic.close(); + } + + @Test + void shouldPassOnRequestWithoutAuthentication() throws Exception { + arrangeAuthenticationInSecurityContext(null); + + filter.doFilterInternal(request, response, filterChain); + + assertRequestIsPassedOnToFilterChain(); + } + + @Test + void shouldRedirectIfZoneInSessionDoesNotMatchZoneInIdentityZoneHolder() throws Exception { + final UaaAuthentication uaaAuthentication = buildUaaAuthenticationMock(ZONE1_ID); + arrangeAuthenticationInSecurityContext(uaaAuthentication); + + arrangeCurrentIdentityZone(ZONE2_ID); + + filter.doFilterInternal(request, response, filterChain); + + assertSessionIsInvalidatedAndRedirectIsPerformed(); + } + + @Test + void shouldPassOnRequestIfZoneInSessionMatchesZoneInIdentityZoneHolder() throws Exception { + final UaaAuthentication uaaAuthentication = buildUaaAuthenticationMock(ZONE1_ID); + arrangeAuthenticationInSecurityContext(uaaAuthentication); + + arrangeCurrentIdentityZone(ZONE1_ID); + + filter.doFilterInternal(request, response, filterChain); + + assertRequestIsPassedOnToFilterChain(); + } + + @Test + void shouldRedirectIfZoneInTokenDoesNotMatchZoneInIdentityZoneHolder() throws Exception { + final UaaOauth2Authentication uaaOauth2Authentication = buildUaaOauth2AuthenticationMock(ZONE1_ID); + arrangeAuthenticationInSecurityContext(uaaOauth2Authentication); + + arrangeCurrentIdentityZone(ZONE2_ID); + + filter.doFilterInternal(request, response, filterChain); + + assertSessionIsInvalidatedAndRedirectIsPerformed(); + } + + @Test + void shouldPassOnRequestIfZoneInTokenMatchesZoneInIdentityZoneHolder() throws Exception { + final UaaOauth2Authentication uaaOauth2Authentication = buildUaaOauth2AuthenticationMock(ZONE1_ID); + arrangeAuthenticationInSecurityContext(uaaOauth2Authentication); + + arrangeCurrentIdentityZone(ZONE1_ID); + + filter.doFilterInternal(request, response, filterChain); + + assertRequestIsPassedOnToFilterChain(); + } + + private void arrangeCurrentIdentityZone(final String zoneId) { + when(identityZoneManager.getCurrentIdentityZoneId()).thenReturn(zoneId); + } + + private void arrangeAuthenticationInSecurityContext(final Authentication authentication) { + final SecurityContext mockSecurityContext = mock(SecurityContext.class); + when(mockSecurityContext.getAuthentication()).thenReturn(authentication); + securityContextHolderMockedStatic.when(SecurityContextHolder::getContext).thenReturn(mockSecurityContext); + } + + private void assertRequestIsPassedOnToFilterChain() throws IOException, ServletException { + verify(filterChain).doFilter(request, response); + } + + private void assertSessionIsInvalidatedAndRedirectIsPerformed() throws IOException { + verify(session).invalidate(); + verify(redirectStrategy).sendRedirect(request, response, REDIRECT_URL); + } + + private static UaaAuthentication buildUaaAuthenticationMock(final String zoneId) { + final UaaAuthentication uaaAuthentication = mock(UaaAuthentication.class); + final UaaPrincipal uaaPrincipal = mock(UaaPrincipal.class); + when(uaaAuthentication.getPrincipal()).thenReturn(uaaPrincipal); + when(uaaPrincipal.getZoneId()).thenReturn(zoneId); + return uaaAuthentication; + } + + private static UaaOauth2Authentication buildUaaOauth2AuthenticationMock(final String zoneId) { + final UaaOauth2Authentication uaaOauth2Authentication = mock(UaaOauth2Authentication.class); + when(uaaOauth2Authentication.getZoneId()).thenReturn(zoneId); + return uaaOauth2Authentication; + } +} \ No newline at end of file diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilterTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilterTests.java index 231a18839fc..75d10e561b4 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilterTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilterTests.java @@ -1,7 +1,10 @@ package org.cloudfoundry.identity.uaa.zone; +import org.apache.commons.lang3.StringUtils; import org.cloudfoundry.identity.uaa.annotations.WithDatabaseContext; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -19,18 +22,28 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; @WithDatabaseContext class IdentityZoneResolvingFilterTests { private boolean wasFilterExecuted; - private IdentityZoneProvisioning dao; + private final IdentityZoneProvisioning dao; - @BeforeEach - void setUp(@Autowired JdbcTemplate jdbcTemplate) { + public IdentityZoneResolvingFilterTests(@Autowired final JdbcTemplate jdbcTemplate) { dao = new JdbcIdentityZoneProvisioning(jdbcTemplate); + } + + @BeforeEach + void setUp() { wasFilterExecuted = false; } @@ -77,7 +90,7 @@ void doNotThrowException_InCase_RetrievingZoneFails() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = Mockito.mock(FilterChain.class); - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setAdditionalInternalHostnames(new HashSet<>(Collections.singletonList(uaaHostname))); filter.doFilter(request, response, chain); @@ -116,7 +129,7 @@ public void doFilter(ServletRequest request, ServletResponse response) throws IO wasFilterExecuted = true; } }; - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setAdditionalInternalHostnames(new HashSet<>(Arrays.asList(uaaHostname))); filter.doFilter(request, response, filterChain); @@ -127,7 +140,7 @@ public void doFilter(ServletRequest request, ServletResponse response) throws IO private void assertFindsCorrectSubdomain(final String subDomainInput, final String incomingHostname, String... additionalInternalHostnames) throws ServletException, IOException { final String expectedSubdomain = subDomainInput.toLowerCase(); - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setAdditionalInternalHostnames(new HashSet<>(Arrays.asList(additionalInternalHostnames))); IdentityZone identityZone = MultitenancyFixture.identityZone(subDomainInput, subDomainInput); @@ -162,7 +175,7 @@ void holderIsNotSetWithNonMatchingIdentityZone() throws Exception { String uaaHostname = "uaa.mycf.com"; String incomingHostname = incomingSubdomain + "." + uaaHostname; - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); FilterChain chain = Mockito.mock(FilterChain.class); filter.setAdditionalInternalHostnames(new HashSet<>(Collections.singletonList(uaaHostname))); @@ -182,21 +195,21 @@ void holderIsNotSetWithNonMatchingIdentityZone() throws Exception { @Test void setDefaultZoneHostNamesWithNull() { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setDefaultInternalHostnames(null); assertThat(filter.getDefaultZoneHostnames()).isEmpty(); } @Test void setAdditionalZoneHostNamesWithNull() { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setAdditionalInternalHostnames(null); assertThat(filter.getDefaultZoneHostnames()).isEmpty(); } @Test void setRestoreZoneHostNamesWithNull() { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setDefaultInternalHostnames(new HashSet<>(Collections.singletonList("uaa.mycf.com"))); filter.restoreDefaultHostnames(null); assertThat(filter.getDefaultZoneHostnames()).isEmpty(); @@ -204,7 +217,7 @@ void setRestoreZoneHostNamesWithNull() { @Test void setDefaultZoneHostNames() { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setDefaultInternalHostnames(new HashSet<>(Collections.singletonList("uaa.mycf.com"))); filter.setDefaultInternalHostnames(new HashSet<>(Collections.singletonList("uaa.MYCF2.com"))); assertThat(filter.getDefaultZoneHostnames()).hasSize(2); @@ -214,7 +227,7 @@ void setDefaultZoneHostNames() { @Test void setAdditionalZoneHostNames() { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setAdditionalInternalHostnames(new HashSet<>(Collections.singletonList("uaa.mycf.com"))); filter.setAdditionalInternalHostnames(new HashSet<>(Collections.singletonList("uaa.MYCF2.com"))); assertThat(filter.getDefaultZoneHostnames()).hasSize(2); @@ -224,10 +237,192 @@ void setAdditionalZoneHostNames() { @Test void setRestoreZoneHostNames() { - IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao); + IdentityZoneResolvingFilter filter = new IdentityZoneResolvingFilter(dao, false); filter.setDefaultInternalHostnames(new HashSet<>(Collections.singletonList("uaa.mycf.com"))); filter.restoreDefaultHostnames(new HashSet<>(Collections.singletonList("uaa.MYCF2.com"))); assertThat(filter.getDefaultZoneHostnames()).hasSize(1); assertThat(filter.getDefaultZoneHostnames()).contains("uaa.mycf2.com"); } + + @Nested + class XZidHeader { + private static final String X_ZID_HEADER = "X-Zid"; + + private final String zoneASubdomain = generateRandomSubdomain(); + private final String zoneAId = UUID.randomUUID().toString(); + private final IdentityZone zoneA = MultitenancyFixture.identityZone(zoneAId, zoneASubdomain); + + private final String zoneBSubdomain = generateRandomSubdomain(); + private final String zoneBId = UUID.randomUUID().toString(); + private final IdentityZone zoneB = MultitenancyFixture.identityZone(zoneBId, zoneBSubdomain); + + private final IdentityZoneProvisioning identityZoneProvisioning = mock(IdentityZoneProvisioning.class); + + private static class Base { + protected final IdentityZoneResolvingFilter filter; + + public Base(final IdentityZoneProvisioning identityZoneProvisioning, final boolean zidHeaderEnabled) { + this.filter = new IdentityZoneResolvingFilter(identityZoneProvisioning, zidHeaderEnabled); + filter.setAdditionalInternalHostnames(Set.of("uaa.mycf.com")); + } + } + + @Nested + class Enabled extends Base { + public Enabled() { + super(identityZoneProvisioning, true); + } + + @Test + void subdomainNotSet_XZidSetToZoneA_ZoneAExists_ShouldUseZoneA() throws ServletException, IOException { + arrangeZoneExists(zoneA); + assertZoneIsResolved(filter, "uaa.mycf.com", zoneAId, zoneA); + } + + @Test + void subdomainSetToZoneA_XZidSetToZoneB_BothZonesExist_ShouldUseZoneB() throws ServletException, IOException { + arrangeZoneExists(zoneA); + arrangeZoneExists(zoneB); + assertZoneIsResolved(filter, zoneA.getSubdomain() + ".uaa.mycf.com", zoneBId, zoneB); + } + + @Test + void subdomainSetToZoneA_XZidSetToZoneB_ZoneBDoesNotExist_ShouldReturn404() throws ServletException, IOException { + arrangeZoneExists(zoneA); + arrangeZoneDoesNotExist(zoneB); + assertZoneIsNotFound(filter, zoneA.getSubdomain() + ".uaa.mycf.com", zoneBId); + } + + @Test + void subdomainNotSet_XZidSetToZoneA_ZoneADoesNotExist_ShouldReturn404() throws ServletException, IOException { + arrangeZoneDoesNotExist(zoneA); + assertZoneIsNotFound(filter, "uaa.mycf.com", zoneAId); + } + + @Test + void subdomainNotSet_XZidEmpty_ShouldReturn404() throws ServletException, IOException { + assertZoneIsNotFound(filter, "uaa.mycf.com", StringUtils.EMPTY); + } + } + + @Nested + class Disabled extends Base { + public Disabled() { + super(identityZoneProvisioning, false); + } + + @Test + void subdomainNotSet_XZidSetToZoneA_ZoneAExists_ShouldIgnoreHeaderAndReturn404() throws ServletException, IOException { + arrangeZoneExists(zoneA); + assertZoneIsNotFound(filter, "uaa.mycf.com", zoneAId); + } + + @Test + void subdomainSetToZoneA_XZidSetToZoneB_BothZonesExist_ShouldIgnoreHeaderAndUseZoneA() throws ServletException, IOException { + arrangeZoneExists(zoneA); + arrangeZoneExists(zoneB); + assertZoneIsResolved(filter, zoneA.getSubdomain() + ".uaa.mycf.com", zoneBId, zoneA); + } + + @Test + void subdomainSetToZoneA_XZidSetToZoneB_ZoneBDoesNotExist_ShouldIgnoreHeaderAndUseZoneA() throws ServletException, IOException { + arrangeZoneExists(zoneA); + arrangeZoneDoesNotExist(zoneB); + assertZoneIsResolved(filter, zoneA.getSubdomain() + ".uaa.mycf.com", zoneBId, zoneA); + } + + @Test + void subdomainNotSet_XZidSetToZoneA_ZoneADoesNotExist_ShouldReturn404() throws ServletException, IOException { + arrangeZoneDoesNotExist(zoneA); + assertZoneIsNotFound(filter, "uaa.mycf.com", zoneAId); + } + + @Test + void subdomainNotSet_XZidEmpty_ShouldReturn404() throws ServletException, IOException { + assertZoneIsNotFound(filter, "uaa.mycf.com", StringUtils.EMPTY); + } + } + + private void assertZoneIsResolved( + final IdentityZoneResolvingFilter filter, + final String hostname, + final String xZidHeader, // null -> no header + final IdentityZone expectedZone + ) throws ServletException, IOException { + final MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServerName(hostname); + if (xZidHeader != null) { + request.addHeader(X_ZID_HEADER, xZidHeader); + } + + final MockHttpServletResponse response = new MockHttpServletResponse(); + + final MockFilterChain filterChain = new MockFilterChain() { + @Override + public void doFilter(final ServletRequest request, final ServletResponse response) { + assertThat(IdentityZoneHolder.get()).isNotNull().isEqualTo(expectedZone); + wasFilterExecuted = true; + } + }; + + filter.doFilter(request, response, filterChain); + + assertThat(wasFilterExecuted).isTrue(); + + // IdZHolder must be reset to the UAA zone after the request is processed + assertThat(IdentityZoneHolder.get()).isEqualTo(IdentityZone.getUaa()); + } + + private void assertZoneIsNotFound( + final IdentityZoneResolvingFilter filter, + final String hostname, + final String xZidHeader // null -> no header + ) throws ServletException, IOException { + final MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServerName(hostname); + if (xZidHeader != null) { + request.addHeader(X_ZID_HEADER, xZidHeader); + } + + final MockHttpServletResponse response = mock(MockHttpServletResponse.class); + + final MockFilterChain filterChain = new MockFilterChain() { + @Override + public void doFilter(final ServletRequest request, final ServletResponse response) { + wasFilterExecuted = true; + } + }; + + filter.doFilter(request, response, filterChain); + + assertThat(wasFilterExecuted).isFalse(); + verify(response).sendError(eq(HttpServletResponse.SC_NOT_FOUND), anyString()); + + // IdZHolder must be reset to the UAA zone after the request is processed + assertThat(IdentityZoneHolder.get()).isEqualTo(IdentityZone.getUaa()); + } + + private void arrangeZoneExists(final IdentityZone identityZone) { + lenient().when(identityZoneProvisioning.retrieveBySubdomain(identityZone.getSubdomain())).thenReturn(identityZone); + lenient().when(identityZoneProvisioning.retrieve(identityZone.getId())).thenReturn(identityZone); + } + + private void arrangeZoneDoesNotExist(final IdentityZone identityZone) { + lenient().when(identityZoneProvisioning.retrieveBySubdomain(identityZone.getSubdomain())) + .thenThrow(new ZoneDoesNotExistsException("zone does not exist")); + lenient().when(identityZoneProvisioning.retrieve(identityZone.getId())) + .thenThrow(new ZoneDoesNotExistsException("zone does not exist")); + } + + private static String generateRandomSubdomain() { + final String randomString = new AlphanumericRandomValueStringGenerator(10).generate().toLowerCase(); + + // ensure string starts with letter + if ('0' <= randomString.charAt(0) && randomString.charAt(0) <= '9') { + return 'a' + randomString.substring(1); + } + + return randomString; + } + } } diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 8d4da3b9859..6a74d7fba78 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -26,6 +26,13 @@ The User Account and Authentication Service (UAA): - supports APIs for user account management for an external web UI - most of the APIs are defined by the specs for the OAuth2, OIDC, and SCIM standards. +## Multi-tenancy: Identity Zones + +UAA supports multi-tenancy through the concept of identity zones. +Each identity zone is accessed through a unique subdomain. + +Since UAA version 77.34.0, the identity zone can be overwritten by providing an identity zone ID in the `X-Zid` header +if `login.zidHeaderEnabled` is set to `true`. # Authorization diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 3f47ed49da0..ef8b8e53c84 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -377,6 +377,7 @@ login: # idpDiscoveryEnabled: true # accountChooserEnabled: true # aliasEntitiesEnabled: true +# zidHeaderEnabled: true # SAML Key Configuration # The location and credentials of the certificate for this SP diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/LoginIT.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/LoginIT.java index 0444cb16549..2f62c37dd8a 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/LoginIT.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/LoginIT.java @@ -14,13 +14,18 @@ package org.cloudfoundry.identity.uaa.integration.feature; import com.dumbster.smtp.SimpleSmtpServer; +import org.apache.http.impl.client.HttpClientBuilder; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.integration.util.IntegrationTestUtils; import org.cloudfoundry.identity.uaa.oauth.client.test.TestAccounts; +import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; import org.cloudfoundry.identity.uaa.security.web.CookieBasedCsrfTokenRepository; import org.cloudfoundry.identity.uaa.test.UaaWebDriver; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.BrandingInformation.Banner; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.AfterEach; @@ -39,16 +44,20 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.util.LinkedMultiValueMap; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestTemplate; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.integration.util.IntegrationTestUtils.doesSupportZoneDNS; import static org.springframework.http.HttpMethod.GET; @@ -211,6 +220,148 @@ void successfulLoginNewUser() { IntegrationTestUtils.validateAccountChooserCookie(baseUrl, webDriver, IdentityZoneHolder.get()); } + @Test + void ShouldReject_SessionReusedForDifferentZoneViaZidHeader_Home() { + // create second identity zone + final String idzId = createCustomIdentityZone(); + + // create RestTemplate with disabled automatic redirects + final RestTemplate template = new RestTemplate(new HttpComponentsClientHttpRequestFactory( + HttpClientBuilder.create().disableRedirectHandling().build() + )); + + // GET /login to get the CSRF cookie + final HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, MediaType.TEXT_HTML_VALUE); + ResponseEntity loginResponse = template.exchange( + baseUrl + "/login", + GET, + new HttpEntity<>(null, headers), + String.class + ); + IntegrationTestUtils.copyCookies(loginResponse, headers); + final String csrfCookie = IntegrationTestUtils.extractCookieCsrf(loginResponse.getBody()); + + // log in to the UAA zone + final LinkedMultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add("username", testAccounts.getUserName()); + requestBody.add("password", testAccounts.getPassword()); + requestBody.add(CookieBasedCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, csrfCookie); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + loginResponse = template.exchange( + baseUrl + "/login.do", + POST, + new HttpEntity<>(requestBody, headers), + String.class + ); + + // try to access the home page of the UAA zone -> should work + IntegrationTestUtils.copyCookies(loginResponse, headers); + final ResponseEntity homeUaaZoneResponse = template.exchange( + baseUrl + "/home", + GET, + new HttpEntity<>(null, headers), + String.class + ); + assertThat(homeUaaZoneResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(homeUaaZoneResponse.getBody()).contains("

Where to?

"); + + /* try to access the home page of another zone (via X-Zid header) with the session cookie of the UAA zone + * -> should redirect to /login */ + headers.set("X-Zid", idzId); + final ResponseEntity homeCustomZoneResponse = template.exchange( + baseUrl + "/home", + GET, + new HttpEntity<>(null, headers), + String.class + ); + assertRedirectsToLoginPage(homeCustomZoneResponse); + + // try to access the home page of the UAA zone again -> should not work as the session is invalidated + headers.remove("X-Zid"); + final ResponseEntity homeUaaZoneAfterInvalidationResponse = template.exchange( + baseUrl + "/home", + GET, + new HttpEntity<>(null, headers), + String.class + ); + assertRedirectsToLoginPage(homeUaaZoneAfterInvalidationResponse); + } + + @Test + void ShouldReject_SessionReusedForDifferentZoneViaZidHeader_OAuthAuthorize() throws MalformedURLException { + // create second identity zone + final String idzId = createCustomIdentityZone(); + + // create an OAuth client in both zones (with the same client ID) + final String redirectUri = "http://localhost:9000/login/callback"; + final String clientId = new RandomValueStringGenerator().generate(); + createOAuthClientInUaaAndCustomZone(clientId, redirectUri, idzId); + + // create RestTemplate with disabled automatic redirects + final RestTemplate template = new RestTemplate(new HttpComponentsClientHttpRequestFactory( + HttpClientBuilder.create().disableRedirectHandling().build() + )); + + // GET /login to get the CSRF cookie + final HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.ACCEPT, MediaType.TEXT_HTML_VALUE); + ResponseEntity loginResponse = template.exchange( + baseUrl + "/login", + GET, + new HttpEntity<>(null, headers), + String.class + ); + IntegrationTestUtils.copyCookies(loginResponse, headers); + final String csrfCookie = IntegrationTestUtils.extractCookieCsrf(loginResponse.getBody()); + + // log in to the UAA zone + final LinkedMultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add("username", testAccounts.getUserName()); + requestBody.add("password", testAccounts.getPassword()); + requestBody.add(CookieBasedCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, csrfCookie); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + loginResponse = template.exchange( + baseUrl + "/login.do", + POST, + new HttpEntity<>(requestBody, headers), + String.class + ); + + // call /oauth/authorize for the UAA zone -> should directly provide the auth code since we are logged in + IntegrationTestUtils.copyCookies(loginResponse, headers); + final String oauthAuthorizeUrl = "%s/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s" + .formatted(baseUrl, clientId, redirectUri); + final ResponseEntity oauthAuthorizeUaaZoneResponse = template.exchange( + oauthAuthorizeUrl, + GET, + new HttpEntity<>(null, headers), + String.class + ); + assertRedirectsToRedirectUriWithCode(oauthAuthorizeUaaZoneResponse, redirectUri); + + /* try to call /oauth/authorize for the custom zone (via X-Zid header) with the session cookie of the UAA zone + * -> should redirect to /login */ + headers.set("X-Zid", idzId); + final ResponseEntity homeCustomZoneResponse = template.exchange( + oauthAuthorizeUrl, + GET, + new HttpEntity<>(null, headers), + String.class + ); + assertRedirectsToLoginPage(homeCustomZoneResponse); + + // try to call /oauth/authorize in the UAA zone again -> should redirect to /login as the session is invalidated + headers.remove("X-Zid"); + final ResponseEntity oauthAuthorizeUaaZoneAfterInvalidationResponse = template.exchange( + oauthAuthorizeUrl, + GET, + new HttpEntity<>(null, headers), + String.class + ); + assertRedirectsToLoginPage(oauthAuthorizeUaaZoneAfterInvalidationResponse); + } + @Test void loginHint() { String newUserEmail = createAnotherUser(); @@ -488,4 +639,63 @@ private void loginThroughDiscovery(String userEmail, String password) { webDriver.findElement(By.id("password")).sendKeys(password); webDriver.clickAndWait(By.xpath("//input[@value='Sign in']")); } + + private static void assertRedirectsToRedirectUriWithCode( + final ResponseEntity response, + final String expectedRedirectUri + ) throws MalformedURLException { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND); + final List locationHeaderValues = response.getHeaders().get("Location"); + assertThat(locationHeaderValues).isNotNull().hasSize(1); + final String locationHeader = locationHeaderValues.get(0); + assertThat(locationHeader).startsWith(expectedRedirectUri); + final URL redirectUriWithCode = new URL(locationHeader); + final String[] queryParameters = redirectUriWithCode.getQuery().split("&"); + assertThat(queryParameters).hasSize(1); + final String queryParameter = queryParameters[0]; + assertThat(queryParameter).startsWith("code="); + } + + private static void assertRedirectsToLoginPage(final ResponseEntity response) { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND); + assertThat(response.getHeaders()).containsKey("Location"); + final List locationHeaderValues = response.getHeaders().get("Location"); + assertThat(locationHeaderValues).isNotNull().hasSize(1); + final String locationHeader = locationHeaderValues.get(0); + assertThat(locationHeader).endsWith("/login"); + } + + private void createOAuthClientInUaaAndCustomZone( + final String clientId, + final String redirectUri, + final String customZoneId + ) { + final UaaClientDetails zoneClient = new UaaClientDetails( + clientId, + null, + "openid,user_attributes", + "authorization_code,client_credentials", + "uaa.admin,scim.read,scim.write,uaa.resource", + redirectUri + ); + zoneClient.setClientSecret("secret"); + zoneClient.setAutoApproveScopes(singleton("true")); + final String clientCredentialsToken = IntegrationTestUtils.getClientCredentialsToken(baseUrl, "admin", "adminsecret"); + IntegrationTestUtils.createClientAsZoneAdmin(clientCredentialsToken, baseUrl, customZoneId, zoneClient); + IntegrationTestUtils.createClientAsZoneAdmin(clientCredentialsToken, baseUrl, IdentityZone.getUaaZoneId(), zoneClient); + } + + private String createCustomIdentityZone() { + final String idzId = new AlphanumericRandomValueStringGenerator(8).generate().toLowerCase(); + final RestTemplate identityClient = IntegrationTestUtils.getClientCredentialsTemplate( + IntegrationTestUtils.getClientCredentialsResource( + baseUrl, + new String[]{"zones.write", "zones.read", "scim.zones"}, + "identity", + "identitysecret" + ) + ); + IntegrationTestUtils.createZoneOrUpdateSubdomain(identityClient, baseUrl, idzId, idzId, new IdentityZoneConfiguration()); + return idzId; + } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java index 95ac8309110..9926eaa6990 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java @@ -379,8 +379,15 @@ public static void setDisableInternalUserManagement(ApplicationContext applicati public static IdentityZone createZoneUsingWebRequest(MockMvc mockMvc, String accessToken) throws Exception { final String zoneId = new AlphanumericRandomValueStringGenerator(12).generate().toLowerCase(); IdentityZone identityZone = MultitenancyFixture.identityZone(zoneId, zoneId); + return createZoneUsingWebRequest(mockMvc, accessToken, identityZone); + } - MvcResult result = mockMvc.perform(post("/identity-zones") + public static IdentityZone createZoneUsingWebRequest( + final MockMvc mockMvc, + final String accessToken, + final IdentityZone identityZone + ) throws Exception { + final MvcResult result = mockMvc.perform(post("/identity-zones") .header("Authorization", "Bearer " + accessToken) .contentType(APPLICATION_JSON) .content(JsonUtils.writeValueAsString(identityZone))) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneResolvingMockMvcTest.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneResolvingMockMvcTest.java index 4d7d2c56aae..a8b3964f2da 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneResolvingMockMvcTest.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneResolvingMockMvcTest.java @@ -14,8 +14,15 @@ package org.cloudfoundry.identity.uaa.mock.zones; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.cloudfoundry.identity.uaa.DefaultTestContext; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.test.TestClient; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneResolvingFilter; +import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -24,12 +31,18 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; import java.util.Arrays; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -39,14 +52,17 @@ class IdentityZoneResolvingMockMvcTest { private Set originalHostnames; private MockMvc mockMvc; + private TestClient testClient; private IdentityZoneResolvingFilter identityZoneResolvingFilter; @BeforeEach void storeSettings( @Autowired MockMvc mockMvc, + @Autowired TestClient testClient, @Autowired FilterRegistrationBean identityZoneResolvingFilter ) { this.mockMvc = mockMvc; + this.testClient = testClient; this.identityZoneResolvingFilter = identityZoneResolvingFilter.getFilter(); originalHostnames = this.identityZoneResolvingFilter.getDefaultZoneHostnames(); @@ -99,4 +115,199 @@ void isNotFound(String hostname) throws Exception { .andExpect(status().isNotFound()); } } + + @Nested + @DefaultTestContext + class XZidHeader { + + private static final String HOST_NO_SUBDOMAIN = "uaa.mycf.com"; + + private final String zone1Id = UUID.randomUUID().toString(); + private final String zone1Subdomain = generateRandomSubdomain(); + + private final String zone2Id = UUID.randomUUID().toString(); + private final String zone2Subdomain = generateRandomSubdomain(); + + private final String nonExistingZoneId = UUID.randomUUID().toString(); + private final String nonExistingZoneSubdomain = generateRandomSubdomain(); + + @BeforeEach + void setUp() throws Exception { + identityZoneResolvingFilter.setDefaultInternalHostnames(Set.of(HOST_NO_SUBDOMAIN, "localhost")); + + createIdz(zone1Id, zone1Subdomain); + createIdz(zone2Id, zone2Subdomain); + } + + @AfterEach + void tearDown() throws Exception { + MockMvcUtils.deleteIdentityZone(zone1Id, mockMvc); + MockMvcUtils.deleteIdentityZone(zone2Id, mockMvc); + } + + @Nested + @DefaultTestContext + class Enabled { + + @BeforeEach + void setUp() { + arrangeZidHeaderEnabled(true); + } + + @Test + void subdomainNotSet_ZidHeaderSetToZone2_Zone2Exists_ShouldReturnZone2() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", HOST_NO_SUBDOMAIN) + .header("X-Zid", zone2Id) + .header("Accept", "application/json") + ).andExpect(resolvedZoneWithName(zone2Subdomain)); + } + + @Test + void subdomainSetToZone1_ZidHeaderSetToZone2_BothZonesExist_ShouldReturnZone2() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", zone1Subdomain + "." + HOST_NO_SUBDOMAIN) + .header("X-Zid", zone2Id) + .header("Accept", "application/json") + ).andExpect(resolvedZoneWithName(zone2Subdomain)); + } + + @Test + void subdomainSetToNonExistingZone_ZidHeaderSetToZone2_ShouldReturnZone2() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", nonExistingZoneSubdomain + "." + HOST_NO_SUBDOMAIN) + .header("X-Zid", zone2Id) + .header("Accept", "application/json") + ).andExpect(resolvedZoneWithName(zone2Subdomain)); + } + + @Test + void subdomainNotSet_ZidHeaderSetToNonExistingZone_ShouldReturn404() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", HOST_NO_SUBDOMAIN) + .header("X-Zid", nonExistingZoneId) + .header("Accept", "application/json") + ).andExpect(status().isNotFound()); + } + + @Test + void subdomainSetToExistingZone_ZidHeaderSetToNonExistingZone_ShouldReturn404() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", zone1Subdomain + "." + HOST_NO_SUBDOMAIN) + .header("X-Zid", nonExistingZoneId) + .header("Accept", "application/json") + ).andExpect(status().isNotFound()); + } + + @AfterEach + void tearDown() { + arrangeZidHeaderEnabled(false); + } + + private void arrangeZidHeaderEnabled(final boolean enabled) { + ReflectionTestUtils.setField(identityZoneResolvingFilter, "zidHeaderEnabled", enabled); + } + } + + @Nested + @DefaultTestContext + class Disabled { + + @Test + void subdomainNotSet_ZidHeaderSetToZone2_Zone2Exists_ShouldReturnUaaZone() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", HOST_NO_SUBDOMAIN) + .header("X-Zid", zone2Id) + .header("Accept", "application/json") + ).andExpect(resolvedUaaZone()); + } + + @Test + void subdomainSetToZone1_ZidHeaderSetToZone2_BothZonesExist_ShouldReturnZone1() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", zone1Subdomain + "." + HOST_NO_SUBDOMAIN) + .header("X-Zid", zone2Id) + .header("Accept", "application/json") + ).andExpect(resolvedZoneWithName(zone1Subdomain)); + } + + @Test + void subdomainSetToNonExistingZone_ZidHeaderSetToZone2_ShouldReturn404() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", nonExistingZoneSubdomain + "." + HOST_NO_SUBDOMAIN) + .header("X-Zid", zone2Id) + .header("Accept", "application/json") + ).andExpect(status().isNotFound()); + } + + @Test + void subdomainNotSet_ZidHeaderSetToNonExistingZone_ShouldReturnUaaZone() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", HOST_NO_SUBDOMAIN) + .header("X-Zid", nonExistingZoneId) + .header("Accept", "application/json") + ).andExpect(resolvedUaaZone()); + } + + @Test + void subdomainSetToZone1_ZidHeaderSetToNonExistingZone_ShouldReturnZone1() throws Exception { + mockMvc.perform( + get("/login") + .header("Host", zone1Subdomain + "." + HOST_NO_SUBDOMAIN) + .header("X-Zid", nonExistingZoneId) + .header("Accept", "application/json") + ).andExpect(resolvedZoneWithName(zone1Subdomain)); + } + } + } + + private void createIdz(final String zoneId, final String subdomain) throws Exception { + final String identityToken = testClient.getClientCredentialsOAuthAccessToken( + "identity", + "identitysecret", + "zones.write"); + final IdentityZone zone = MultitenancyFixture.identityZone(zoneId, subdomain); + zone.setName(subdomain); + MockMvcUtils.createZoneUsingWebRequest(mockMvc, identityToken, zone); + } + + private static ResultMatcher resolvedUaaZone() { + return resolvedZoneWithName(IdentityZone.getUaa().getName()); + } + + private static ResultMatcher resolvedZoneWithName(final String idzName) { + final ObjectMapper objectMapper = new ObjectMapper(); + return result -> { + final MockHttpServletResponse response = result.getResponse(); + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(200); + + final String responseContentAsString = response.getContentAsString(); + assertThat(responseContentAsString).isNotNull(); + final Map responseContent = objectMapper.readValue( + responseContentAsString, + new TypeReference<>() { + } + ); + assertThat(responseContent).containsEntry("zone_name", idzName); + }; + } + + private static String generateRandomSubdomain() { + final String randomString = new AlphanumericRandomValueStringGenerator(8).generate().toLowerCase(); + if ('0' <= randomString.charAt(0) && randomString.charAt(0) <= '9') { + // Ensure the first character is not a digit, as subdomains cannot start with a digit + return "a" + randomString.substring(1); + } + return randomString; + } }