diff --git a/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationFilter.java b/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationFilter.java index 94c783e3a2a..4727f1dd6e9 100644 --- a/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationFilter.java +++ b/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationFilter.java @@ -22,6 +22,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.apereo.cas.client.proxy.ProxyGrantingTicketStorage; import org.apereo.cas.client.util.WebUtils; import org.apereo.cas.client.validation.TicketValidator; @@ -39,14 +40,20 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.savedrequest.SavedRequest; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * Processes a CAS service ticket, obtains proxy granting tickets, and processes proxy @@ -199,6 +206,10 @@ public class CasAuthenticationFilter extends AbstractAuthenticationProcessingFil private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder .getContextHolderStrategy(); + private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private RequestCache requestCache = new HttpSessionRequestCache(); + public CasAuthenticationFilter() { super("/login/cas"); setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler()); @@ -237,7 +248,22 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ return null; } String serviceTicket = obtainArtifact(request); - if (serviceTicket == null) { + if (!StringUtils.hasText(serviceTicket)) { + HttpSession session = request.getSession(false); + if (session != null && session + .getAttribute(CasGatewayAuthenticationRedirectFilter.CAS_GATEWAY_AUTHENTICATION_ATTR) != null) { + this.logger.debug("Failed authentication response from CAS gateway request"); + session.removeAttribute(CasGatewayAuthenticationRedirectFilter.CAS_GATEWAY_AUTHENTICATION_ATTR); + SavedRequest savedRequest = this.requestCache.getRequest(request, response); + if (savedRequest != null) { + String redirectUrl = savedRequest.getRedirectUrl(); + this.logger.debug(LogMessage.format("Redirecting to: %s", redirectUrl)); + this.requestCache.removeRequest(request, response); + this.redirectStrategy.sendRedirect(request, response, redirectUrl); + return null; + } + } + this.logger.debug("Failed to obtain an artifact (cas ticket)"); serviceTicket = ""; } @@ -303,6 +329,28 @@ public final void setServiceProperties(final ServiceProperties serviceProperties this.authenticateAllArtifacts = serviceProperties.isAuthenticateAllArtifacts(); } + /** + * Set the {@link RedirectStrategy} used to redirect to the saved request if there is + * one saved. Defaults to {@link DefaultRedirectStrategy}. + * @param redirectStrategy the redirect strategy to use + * @since 6.3 + */ + public final void setRedirectStrategy(RedirectStrategy redirectStrategy) { + Assert.notNull(redirectStrategy, "redirectStrategy cannot be null"); + this.redirectStrategy = redirectStrategy; + } + + /** + * The {@link RequestCache} used to retrieve the saved request in failed gateway + * authentication scenarios. + * @param requestCache the request cache to use + * @since 6.3 + */ + public final void setRequestCache(RequestCache requestCache) { + Assert.notNull(requestCache, "requestCache cannot be null"); + this.requestCache = requestCache; + } + /** * Indicates if the request is elgible to process a service ticket. This method exists * for readability. diff --git a/cas/src/main/java/org/springframework/security/cas/web/CasGatewayAuthenticationRedirectFilter.java b/cas/src/main/java/org/springframework/security/cas/web/CasGatewayAuthenticationRedirectFilter.java new file mode 100644 index 00000000000..7dbcbd6b2a7 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/CasGatewayAuthenticationRedirectFilter.java @@ -0,0 +1,126 @@ +/* + * Copyright 2002-2023 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.cas.web; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.apereo.cas.client.util.CommonUtils; +import org.apereo.cas.client.util.WebUtils; + +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.GenericFilterBean; + +/** + * Redirects the request to the CAS server appending {@code gateway=true} to the URL. Upon + * redirection, the {@link ServiceProperties#isSendRenew()} is ignored and considered as + * {@code false} to align with the specification says that the {@code sendRenew} parameter + * is not compatible with the {@code gateway} parameter. See the CAS + * Protocol Specification for more details. To allow other filters to know if the + * request is a gateway request, this filter creates a session and add an attribute with + * name {@link #CAS_GATEWAY_AUTHENTICATION_ATTR} which can be checked by other filters if + * needed. It is recommended that this filter is placed after + * {@link CasAuthenticationFilter} if it is defined. + * + * @author Michael Remond + * @author Jerome LELEU + * @author Marcus da Coregio + * @since 6.3 + */ +public final class CasGatewayAuthenticationRedirectFilter extends GenericFilterBean { + + public static final String CAS_GATEWAY_AUTHENTICATION_ATTR = "CAS_GATEWAY_AUTHENTICATION"; + + private final String casLoginUrl; + + private final ServiceProperties serviceProperties; + + private RequestMatcher requestMatcher; + + private RequestCache requestCache = new HttpSessionRequestCache(); + + private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + /** + * Constructs a new instance of this class + * @param serviceProperties the {@link ServiceProperties} + */ + public CasGatewayAuthenticationRedirectFilter(String casLoginUrl, ServiceProperties serviceProperties) { + Assert.hasText(casLoginUrl, "casLoginUrl cannot be null or empty"); + Assert.notNull(serviceProperties, "serviceProperties cannot be null"); + this.casLoginUrl = casLoginUrl; + this.serviceProperties = serviceProperties; + this.requestMatcher = new CasGatewayResolverRequestMatcher(this.serviceProperties); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + if (!this.requestMatcher.matches(request)) { + chain.doFilter(request, response); + return; + } + + this.requestCache.saveRequest(request, response); + HttpSession session = request.getSession(true); + session.setAttribute(CAS_GATEWAY_AUTHENTICATION_ATTR, true); + String urlEncodedService = WebUtils.constructServiceUrl(request, response, this.serviceProperties.getService(), + null, this.serviceProperties.getServiceParameter(), this.serviceProperties.getArtifactParameter(), + true); + String redirectUrl = CommonUtils.constructRedirectUrl(this.casLoginUrl, + this.serviceProperties.getServiceParameter(), urlEncodedService, false, true); + this.redirectStrategy.sendRedirect(request, response, redirectUrl); + } + + /** + * Sets the {@link RequestMatcher} used to trigger this filter. Defaults to + * {@link CasGatewayResolverRequestMatcher}. + * @param requestMatcher the {@link RequestMatcher} to use + */ + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + + /** + * Sets the {@link RequestCache} used to store the current request to be replayed + * after redirect from the CAS server. Defaults to {@link HttpSessionRequestCache}. + * @param requestCache the {@link RequestCache} to use + */ + public void setRequestCache(RequestCache requestCache) { + Assert.notNull(requestCache, "requestCache cannot be null"); + this.requestCache = requestCache; + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/web/CasGatewayResolverRequestMatcher.java b/cas/src/main/java/org/springframework/security/cas/web/CasGatewayResolverRequestMatcher.java new file mode 100644 index 00000000000..9332ebc136f --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/CasGatewayResolverRequestMatcher.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2023 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.cas.web; + +import jakarta.servlet.http.HttpServletRequest; +import org.apereo.cas.client.authentication.DefaultGatewayResolverImpl; +import org.apereo.cas.client.authentication.GatewayResolver; + +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * A {@link RequestMatcher} implementation that delegates the check to an instance of + * {@link GatewayResolver}. The request is marked as "gatewayed" using the configured + * {@link GatewayResolver} to avoid infinite loop. + * + * @author Michael Remond + * @author Marcus da Coregio + * @since 6.3 + */ +public final class CasGatewayResolverRequestMatcher implements RequestMatcher { + + private final ServiceProperties serviceProperties; + + private GatewayResolver gatewayStorage = new DefaultGatewayResolverImpl(); + + public CasGatewayResolverRequestMatcher(ServiceProperties serviceProperties) { + Assert.notNull(serviceProperties, "serviceProperties cannot be null"); + this.serviceProperties = serviceProperties; + } + + @Override + public boolean matches(HttpServletRequest request) { + boolean wasGatewayed = this.gatewayStorage.hasGatewayedAlready(request, this.serviceProperties.getService()); + if (!wasGatewayed) { + this.gatewayStorage.storeGatewayInformation(request, this.serviceProperties.getService()); + return true; + } + return false; + } + + /** + * Sets the {@link GatewayResolver} to check if the request was already gatewayed. + * Defaults to {@link DefaultGatewayResolverImpl} + * @param gatewayStorage the {@link GatewayResolver} to use. Cannot be null. + */ + public void setGatewayStorage(GatewayResolver gatewayStorage) { + Assert.notNull(gatewayStorage, "gatewayStorage cannot be null"); + this.gatewayStorage = gatewayStorage; + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationFilterTests.java b/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationFilterTests.java index 46689b42e96..50bc5cdffed 100644 --- a/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationFilterTests.java +++ b/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationFilterTests.java @@ -17,6 +17,7 @@ package org.springframework.security.cas.web; import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpSession; import org.apereo.cas.client.proxy.ProxyGrantingTicketStorage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -36,6 +37,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -219,4 +221,23 @@ public void successfulAuthenticationWhenProxyRequestThenSavesSecurityContext() t verify(securityContextRepository).saveContext(any(SecurityContext.class), eq(request), eq(response)); } + @Test + public void attemptAuthenticationWhenNoServiceTicketAndIsGatewayRequestThenRedirectToSavedRequestAndClearAttribute() + throws Exception { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + session.setAttribute(CasGatewayAuthenticationRedirectFilter.CAS_GATEWAY_AUTHENTICATION_ATTR, true); + + new HttpSessionRequestCache().saveRequest(request, response); + + Authentication authn = filter.attemptAuthentication(request, response); + assertThat(authn).isNull(); + assertThat(response.getStatus()).isEqualTo(302); + assertThat(response.getRedirectedUrl()).isEqualTo("http://localhost?continue"); + assertThat(session.getAttribute(CasGatewayAuthenticationRedirectFilter.CAS_GATEWAY_AUTHENTICATION_ATTR)) + .isNull(); + } + } diff --git a/cas/src/test/java/org/springframework/security/cas/web/CasGatewayAuthenticationRedirectFilterTests.java b/cas/src/test/java/org/springframework/security/cas/web/CasGatewayAuthenticationRedirectFilterTests.java new file mode 100644 index 00000000000..fd7af4a8711 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/web/CasGatewayAuthenticationRedirectFilterTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2023 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.cas.web; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.web.savedrequest.RequestCache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link CasGatewayAuthenticationRedirectFilter}. + * + * @author Jerome LELEU + * @author Marcus da Coregio + */ +public class CasGatewayAuthenticationRedirectFilterTests { + + private static final String CAS_LOGIN_URL = "http://mycasserver/login"; + + CasGatewayAuthenticationRedirectFilter filter = new CasGatewayAuthenticationRedirectFilter(CAS_LOGIN_URL, + serviceProperties()); + + @Test + void doFilterWhenMatchesThenSavesRequestAndSavesAttributeAndSendRedirect() throws IOException, ServletException { + RequestCache requestCache = mock(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.filter.setRequestMatcher((req) -> true); + this.filter.setRequestCache(requestCache); + this.filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader("Location")) + .isEqualTo("http://mycasserver/login?service=http%3A%2F%2Flocalhost%2Flogin%2Fcas&gateway=true"); + verify(requestCache).saveRequest(request, response); + } + + @Test + void doFilterWhenNotMatchThenContinueFilter() throws ServletException, IOException { + this.filter.setRequestMatcher((req) -> false); + FilterChain chain = mock(); + MockHttpServletResponse response = mock(); + this.filter.doFilter(new MockHttpServletRequest(), response, chain); + verify(chain).doFilter(any(), any()); + verifyNoInteractions(response); + } + + @Test + void doFilterWhenSendRenewTrueThenIgnores() throws ServletException, IOException { + ServiceProperties serviceProperties = serviceProperties(); + serviceProperties.setSendRenew(true); + this.filter = new CasGatewayAuthenticationRedirectFilter(CAS_LOGIN_URL, serviceProperties); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.filter.setRequestMatcher((req) -> true); + this.filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader("Location")) + .isEqualTo("http://mycasserver/login?service=http%3A%2F%2Flocalhost%2Flogin%2Fcas&gateway=true"); + } + + private static ServiceProperties serviceProperties() { + ServiceProperties serviceProperties = new ServiceProperties(); + serviceProperties.setService("http://localhost/login/cas"); + return serviceProperties; + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/web/CasGatewayResolverRequestMatcherTests.java b/cas/src/test/java/org/springframework/security/cas/web/CasGatewayResolverRequestMatcherTests.java new file mode 100644 index 00000000000..97a590c068c --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/web/CasGatewayResolverRequestMatcherTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2023 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.cas.web; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.cas.ServiceProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests {@link CasGatewayResolverRequestMatcher}. + * + * @author Marcus da Coregio + */ +class CasGatewayResolverRequestMatcherTests { + + CasGatewayResolverRequestMatcher matcher = new CasGatewayResolverRequestMatcher(new ServiceProperties()); + + @Test + void constructorWhenServicePropertiesNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> new CasGatewayResolverRequestMatcher(null)) + .withMessage("serviceProperties cannot be null"); + } + + @Test + void matchesWhenAlreadyGatewayedThenReturnsFalse() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute("_const_cas_gateway_", "yes"); + boolean matches = this.matcher.matches(request); + assertThat(matches).isFalse(); + } + + @Test + void matchesWhenNotGatewayedThenReturnsTrue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + boolean matches = this.matcher.matches(request); + assertThat(matches).isTrue(); + } + + @Test + void matchesWhenNoSessionThenReturnsTrue() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(null); + boolean matches = this.matcher.matches(request); + assertThat(matches).isTrue(); + } + + @Test + void matchesWhenNotGatewayedAndCheckedAgainThenSavesAsGatewayedAndReturnsFalse() { + MockHttpServletRequest request = new MockHttpServletRequest(); + boolean matches = this.matcher.matches(request); + boolean secondMatch = this.matcher.matches(request); + assertThat(matches).isTrue(); + assertThat(secondMatch).isFalse(); + } + +} diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index f163563774d..9fa19e003d4 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -7,3 +7,7 @@ Below are the highlights of the release. == Configuration - https://github.com/spring-projects/spring-security/issues/6192[gh-6192] - xref:reactive/authentication/concurrent-sessions-control.adoc[docs] Add Concurrent Sessions Control on WebFlux + +== CAS + +- https://github.com/spring-projects/spring-security/pull/14193[gh-14193] - Added support for CAS Gateway Authentication