diff --git a/cas/spring-security-cas.gradle b/cas/spring-security-cas.gradle new file mode 100644 index 0000000000..f340ec7dda --- /dev/null +++ b/cas/spring-security-cas.gradle @@ -0,0 +1,25 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + management platform(project(":spring-security-dependencies")) + api project(':spring-security-core') + api project(':spring-security-web') + api 'org.jasig.cas.client:cas-client-core' + api 'org.springframework:spring-beans' + api 'org.springframework:spring-context' + api 'org.springframework:spring-core' + api 'org.springframework:spring-web' + + optional 'com.fasterxml.jackson.core:jackson-databind' + + provided 'jakarta.servlet:jakarta.servlet-api' + + testImplementation "org.assertj:assertj-core" + testImplementation "org.junit.jupiter:junit-jupiter-api" + testImplementation "org.junit.jupiter:junit-jupiter-params" + testImplementation "org.junit.jupiter:junit-jupiter-engine" + testImplementation "org.mockito:mockito-core" + testImplementation "org.mockito:mockito-junit-jupiter" + testImplementation "org.springframework:spring-test" + testImplementation 'org.skyscreamer:jsonassert' +} diff --git a/cas/src/main/java/org/springframework/security/cas/SamlServiceProperties.java b/cas/src/main/java/org/springframework/security/cas/SamlServiceProperties.java new file mode 100644 index 0000000000..8e300f8f8e --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/SamlServiceProperties.java @@ -0,0 +1,37 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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; + +/** + * Sets the appropriate parameters for CAS's implementation of SAML (which is not + * guaranteed to be actually SAML compliant). + * + * @author Scott Battaglia + * @since 3.0 + */ +public final class SamlServiceProperties extends ServiceProperties { + + public static final String DEFAULT_SAML_ARTIFACT_PARAMETER = "SAMLart"; + + public static final String DEFAULT_SAML_SERVICE_PARAMETER = "TARGET"; + + public SamlServiceProperties() { + super.setArtifactParameter(DEFAULT_SAML_ARTIFACT_PARAMETER); + super.setServiceParameter(DEFAULT_SAML_SERVICE_PARAMETER); + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/ServiceProperties.java b/cas/src/main/java/org/springframework/security/cas/ServiceProperties.java new file mode 100644 index 0000000000..caf03dd62a --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/ServiceProperties.java @@ -0,0 +1,132 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; + +/** + * Stores properties related to this CAS service. + *

+ * Each web application capable of processing CAS tickets is known as a service. This + * class stores the properties that are relevant to the local CAS service, being the + * application that is being secured by Spring Security. + * + * @author Ben Alex + */ +public class ServiceProperties implements InitializingBean { + + public static final String DEFAULT_CAS_ARTIFACT_PARAMETER = "ticket"; + + public static final String DEFAULT_CAS_SERVICE_PARAMETER = "service"; + + private String service; + + private boolean authenticateAllArtifacts; + + private boolean sendRenew = false; + + private String artifactParameter = DEFAULT_CAS_ARTIFACT_PARAMETER; + + private String serviceParameter = DEFAULT_CAS_SERVICE_PARAMETER; + + @Override + public void afterPropertiesSet() { + Assert.hasLength(this.service, "service cannot be empty."); + Assert.hasLength(this.artifactParameter, "artifactParameter cannot be empty."); + Assert.hasLength(this.serviceParameter, "serviceParameter cannot be empty."); + } + + /** + * Represents the service the user is authenticating to. + *

+ * This service is the callback URL belonging to the local Spring Security System for + * Spring secured application. For example, + * + *

+	 * https://www.mycompany.com/application/login/cas
+	 * 
+ * @return the URL of the service the user is authenticating to + */ + public final String getService() { + return this.service; + } + + /** + * Indicates whether the renew parameter should be sent to the CAS login + * URL and CAS validation URL. + *

+ * If true, it will force CAS to authenticate the user again (even if the + * user has previously authenticated). During ticket validation it will require the + * ticket was generated as a consequence of an explicit login. High security + * applications would probably set this to true. Defaults to + * false, providing automated single sign on. + * @return whether to send the renew parameter to CAS + */ + public final boolean isSendRenew() { + return this.sendRenew; + } + + public final void setSendRenew(final boolean sendRenew) { + this.sendRenew = sendRenew; + } + + public final void setService(final String service) { + this.service = service; + } + + public final String getArtifactParameter() { + return this.artifactParameter; + } + + /** + * Configures the Request Parameter to look for when attempting to see if a CAS ticket + * was sent from the server. + * @param artifactParameter the id to use. Default is "ticket". + */ + public final void setArtifactParameter(final String artifactParameter) { + this.artifactParameter = artifactParameter; + } + + /** + * Configures the Request parameter to look for when attempting to send a request to + * CAS. + * @return the service parameter to use. Default is "service". + */ + public final String getServiceParameter() { + return this.serviceParameter; + } + + public final void setServiceParameter(final String serviceParameter) { + this.serviceParameter = serviceParameter; + } + + public final boolean isAuthenticateAllArtifacts() { + return this.authenticateAllArtifacts; + } + + /** + * If true, then any non-null artifact (ticket) should be authenticated. Additionally, + * the service will be determined dynamically in order to ensure the service matches + * the expected value for this artifact. + * @param authenticateAllArtifacts + */ + public final void setAuthenticateAllArtifacts(final boolean authenticateAllArtifacts) { + this.authenticateAllArtifacts = authenticateAllArtifacts; + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/CasAssertionAuthenticationToken.java b/cas/src/main/java/org/springframework/security/cas/authentication/CasAssertionAuthenticationToken.java new file mode 100644 index 0000000000..d04d30d154 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/CasAssertionAuthenticationToken.java @@ -0,0 +1,60 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import java.util.ArrayList; + +import org.jasig.cas.client.validation.Assertion; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.SpringSecurityCoreVersion; + +/** + * Temporary authentication object needed to load the user details service. + * + * @author Scott Battaglia + * @since 3.0 + */ +public final class CasAssertionAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private final Assertion assertion; + + private final String ticket; + + public CasAssertionAuthenticationToken(final Assertion assertion, final String ticket) { + super(new ArrayList<>()); + this.assertion = assertion; + this.ticket = ticket; + } + + @Override + public Object getPrincipal() { + return this.assertion.getPrincipal().getName(); + } + + @Override + public Object getCredentials() { + return this.ticket; + } + + public Assertion getAssertion() { + return this.assertion; + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationProvider.java b/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationProvider.java new file mode 100644 index 0000000000..3a84c2109a --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationProvider.java @@ -0,0 +1,244 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jasig.cas.client.validation.Assertion; +import org.jasig.cas.client.validation.TicketValidationException; +import org.jasig.cas.client.validation.TicketValidator; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.AccountStatusUserDetailsChecker; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.cas.web.CasAuthenticationFilter; +import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper; +import org.springframework.security.core.userdetails.UserDetailsChecker; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation that integrates with JA-SIG Central + * Authentication Service (CAS). + *

+ * This AuthenticationProvider is capable of validating + * {@link UsernamePasswordAuthenticationToken} requests which contain a + * principal name equal to either + * {@link CasAuthenticationFilter#CAS_STATEFUL_IDENTIFIER} or + * {@link CasAuthenticationFilter#CAS_STATELESS_IDENTIFIER}. It can also validate a + * previously created {@link CasAuthenticationToken}. + * + * @author Ben Alex + * @author Scott Battaglia + */ +public class CasAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware { + + private static final Log logger = LogFactory.getLog(CasAuthenticationProvider.class); + + private AuthenticationUserDetailsService authenticationUserDetailsService; + + private final UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker(); + + protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + + private StatelessTicketCache statelessTicketCache = new NullStatelessTicketCache(); + + private String key; + + private TicketValidator ticketValidator; + + private ServiceProperties serviceProperties; + + private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + @Override + public void afterPropertiesSet() { + Assert.notNull(this.authenticationUserDetailsService, "An authenticationUserDetailsService must be set"); + Assert.notNull(this.ticketValidator, "A ticketValidator must be set"); + Assert.notNull(this.statelessTicketCache, "A statelessTicketCache must be set"); + Assert.hasText(this.key, + "A Key is required so CasAuthenticationProvider can identify tokens it previously authenticated"); + Assert.notNull(this.messages, "A message source must be set"); + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (!supports(authentication.getClass())) { + return null; + } + if (authentication instanceof UsernamePasswordAuthenticationToken + && (!CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER.equals(authentication.getPrincipal().toString()) + && !CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER + .equals(authentication.getPrincipal().toString()))) { + // UsernamePasswordAuthenticationToken not CAS related + return null; + } + // If an existing CasAuthenticationToken, just check we created it + if (authentication instanceof CasAuthenticationToken) { + if (this.key.hashCode() != ((CasAuthenticationToken) authentication).getKeyHash()) { + throw new BadCredentialsException(this.messages.getMessage("CasAuthenticationProvider.incorrectKey", + "The presented CasAuthenticationToken does not contain the expected key")); + } + return authentication; + } + + // Ensure credentials are presented + if ((authentication.getCredentials() == null) || "".equals(authentication.getCredentials())) { + throw new BadCredentialsException(this.messages.getMessage("CasAuthenticationProvider.noServiceTicket", + "Failed to provide a CAS service ticket to validate")); + } + + boolean stateless = (authentication instanceof UsernamePasswordAuthenticationToken + && CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER.equals(authentication.getPrincipal())); + CasAuthenticationToken result = null; + + if (stateless) { + // Try to obtain from cache + result = this.statelessTicketCache.getByTicketId(authentication.getCredentials().toString()); + } + if (result == null) { + result = this.authenticateNow(authentication); + result.setDetails(authentication.getDetails()); + } + if (stateless) { + // Add to cache + this.statelessTicketCache.putTicketInCache(result); + } + return result; + } + + private CasAuthenticationToken authenticateNow(final Authentication authentication) throws AuthenticationException { + try { + Assertion assertion = this.ticketValidator.validate(authentication.getCredentials().toString(), + getServiceUrl(authentication)); + UserDetails userDetails = loadUserByAssertion(assertion); + this.userDetailsChecker.check(userDetails); + return new CasAuthenticationToken(this.key, userDetails, authentication.getCredentials(), + this.authoritiesMapper.mapAuthorities(userDetails.getAuthorities()), userDetails, assertion); + } + catch (TicketValidationException ex) { + throw new BadCredentialsException(ex.getMessage(), ex); + } + } + + /** + * Gets the serviceUrl. If the {@link Authentication#getDetails()} is an instance of + * {@link ServiceAuthenticationDetails}, then + * {@link ServiceAuthenticationDetails#getServiceUrl()} is used. Otherwise, the + * {@link ServiceProperties#getService()} is used. + * @param authentication + * @return + */ + private String getServiceUrl(Authentication authentication) { + String serviceUrl; + if (authentication.getDetails() instanceof ServiceAuthenticationDetails) { + return ((ServiceAuthenticationDetails) authentication.getDetails()).getServiceUrl(); + } + Assert.state(this.serviceProperties != null, + "serviceProperties cannot be null unless Authentication.getDetails() implements ServiceAuthenticationDetails."); + Assert.state(this.serviceProperties.getService() != null, + "serviceProperties.getService() cannot be null unless Authentication.getDetails() implements ServiceAuthenticationDetails."); + serviceUrl = this.serviceProperties.getService(); + logger.debug(LogMessage.format("serviceUrl = %s", serviceUrl)); + return serviceUrl; + } + + /** + * Template method for retrieving the UserDetails based on the assertion. Default is + * to call configured userDetailsService and pass the username. Deployers can override + * this method and retrieve the user based on any criteria they desire. + * @param assertion The CAS Assertion. + * @return the UserDetails. + */ + protected UserDetails loadUserByAssertion(final Assertion assertion) { + final CasAssertionAuthenticationToken token = new CasAssertionAuthenticationToken(assertion, ""); + return this.authenticationUserDetailsService.loadUserDetails(token); + } + + @SuppressWarnings("unchecked") + /** + * Sets the UserDetailsService to use. This is a convenience method to invoke + */ + public void setUserDetailsService(final UserDetailsService userDetailsService) { + this.authenticationUserDetailsService = new UserDetailsByNameServiceWrapper(userDetailsService); + } + + public void setAuthenticationUserDetailsService( + final AuthenticationUserDetailsService authenticationUserDetailsService) { + this.authenticationUserDetailsService = authenticationUserDetailsService; + } + + public void setServiceProperties(final ServiceProperties serviceProperties) { + this.serviceProperties = serviceProperties; + } + + protected String getKey() { + return this.key; + } + + public void setKey(String key) { + this.key = key; + } + + public StatelessTicketCache getStatelessTicketCache() { + return this.statelessTicketCache; + } + + protected TicketValidator getTicketValidator() { + return this.ticketValidator; + } + + @Override + public void setMessageSource(final MessageSource messageSource) { + this.messages = new MessageSourceAccessor(messageSource); + } + + public void setStatelessTicketCache(final StatelessTicketCache statelessTicketCache) { + this.statelessTicketCache = statelessTicketCache; + } + + public void setTicketValidator(final TicketValidator ticketValidator) { + this.ticketValidator = ticketValidator; + } + + public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) { + this.authoritiesMapper = authoritiesMapper; + } + + @Override + public boolean supports(final Class authentication) { + return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication)) + || (CasAuthenticationToken.class.isAssignableFrom(authentication)) + || (CasAssertionAuthenticationToken.class.isAssignableFrom(authentication)); + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java b/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java new file mode 100644 index 0000000000..8020da0400 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/CasAuthenticationToken.java @@ -0,0 +1,173 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import java.io.Serializable; +import java.util.Collection; + +import org.jasig.cas.client.validation.Assertion; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Represents a successful CAS Authentication. + * + * @author Ben Alex + * @author Scott Battaglia + */ +public class CasAuthenticationToken extends AbstractAuthenticationToken implements Serializable { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private final Object credentials; + + private final Object principal; + + private final UserDetails userDetails; + + private final int keyHash; + + private final Assertion assertion; + + /** + * Constructor. + * @param key to identify if this object made by a given + * {@link CasAuthenticationProvider} + * @param principal typically the UserDetails object (cannot be null) + * @param credentials the service/proxy ticket ID from CAS (cannot be + * null) + * @param authorities the authorities granted to the user (from the + * {@link org.springframework.security.core.userdetails.UserDetailsService}) (cannot + * be null) + * @param userDetails the user details (from the + * {@link org.springframework.security.core.userdetails.UserDetailsService}) (cannot + * be null) + * @param assertion the assertion returned from the CAS servers. It contains the + * principal and how to obtain a proxy ticket for the user. + * @throws IllegalArgumentException if a null was passed + */ + public CasAuthenticationToken(final String key, final Object principal, final Object credentials, + final Collection authorities, final UserDetails userDetails, + final Assertion assertion) { + this(extractKeyHash(key), principal, credentials, authorities, userDetails, assertion); + } + + /** + * Private constructor for Jackson Deserialization support + * @param keyHash hashCode of provided key to identify if this object made by a given + * {@link CasAuthenticationProvider} + * @param principal typically the UserDetails object (cannot be null) + * @param credentials the service/proxy ticket ID from CAS (cannot be + * null) + * @param authorities the authorities granted to the user (from the + * {@link org.springframework.security.core.userdetails.UserDetailsService}) (cannot + * be null) + * @param userDetails the user details (from the + * {@link org.springframework.security.core.userdetails.UserDetailsService}) (cannot + * be null) + * @param assertion the assertion returned from the CAS servers. It contains the + * principal and how to obtain a proxy ticket for the user. + * @throws IllegalArgumentException if a null was passed + * @since 4.2 + */ + private CasAuthenticationToken(final Integer keyHash, final Object principal, final Object credentials, + final Collection authorities, final UserDetails userDetails, + final Assertion assertion) { + super(authorities); + if ((principal == null) || "".equals(principal) || (credentials == null) || "".equals(credentials) + || (authorities == null) || (userDetails == null) || (assertion == null)) { + throw new IllegalArgumentException("Cannot pass null or empty values to constructor"); + } + this.keyHash = keyHash; + this.principal = principal; + this.credentials = credentials; + this.userDetails = userDetails; + this.assertion = assertion; + setAuthenticated(true); + } + + private static Integer extractKeyHash(String key) { + Assert.hasLength(key, "key cannot be null or empty"); + return key.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + if (!super.equals(obj)) { + return false; + } + if (obj instanceof CasAuthenticationToken) { + CasAuthenticationToken test = (CasAuthenticationToken) obj; + if (!this.assertion.equals(test.getAssertion())) { + return false; + } + if (this.getKeyHash() != test.getKeyHash()) { + return false; + } + return true; + } + return false; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + this.credentials.hashCode(); + result = 31 * result + this.principal.hashCode(); + result = 31 * result + this.userDetails.hashCode(); + result = 31 * result + this.keyHash; + result = 31 * result + ObjectUtils.nullSafeHashCode(this.assertion); + return result; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + public int getKeyHash() { + return this.keyHash; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + public Assertion getAssertion() { + return this.assertion; + } + + public UserDetails getUserDetails() { + return this.userDetails; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(super.toString()); + sb.append(" Assertion: ").append(this.assertion); + sb.append(" Credentials (Service/Proxy Ticket): ").append(this.credentials); + return (sb.toString()); + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/NullStatelessTicketCache.java b/cas/src/main/java/org/springframework/security/cas/authentication/NullStatelessTicketCache.java new file mode 100644 index 0000000000..4284161a39 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/NullStatelessTicketCache.java @@ -0,0 +1,64 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +/** + * Implementation of @link {@link StatelessTicketCache} that has no backing cache. Useful + * in instances where storing of tickets for stateless session management is not required. + *

+ * This is the default StatelessTicketCache of the @link {@link CasAuthenticationProvider} + * to eliminate the unnecessary dependency on EhCache that applications have even if they + * are not using the stateless session management. + * + * @author Scott Battaglia + * @see CasAuthenticationProvider + */ +public final class NullStatelessTicketCache implements StatelessTicketCache { + + /** + * @return null since we are not storing any tickets. + */ + @Override + public CasAuthenticationToken getByTicketId(final String serviceTicket) { + return null; + } + + /** + * This is a no-op since we are not storing tickets. + */ + @Override + public void putTicketInCache(final CasAuthenticationToken token) { + // nothing to do + } + + /** + * This is a no-op since we are not storing tickets. + */ + @Override + public void removeTicketFromCache(final CasAuthenticationToken token) { + // nothing to do + } + + /** + * This is a no-op since we are not storing tickets. + */ + @Override + public void removeTicketFromCache(final String serviceTicket) { + // nothing to do + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/SpringCacheBasedTicketCache.java b/cas/src/main/java/org/springframework/security/cas/authentication/SpringCacheBasedTicketCache.java new file mode 100644 index 0000000000..b72e824c75 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/SpringCacheBasedTicketCache.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2013 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.authentication; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cache.Cache; +import org.springframework.core.log.LogMessage; +import org.springframework.util.Assert; + +/** + * Caches tickets using a Spring IoC defined {@link Cache}. + * + * @author Marten Deinum + * @since 3.2 + * + */ +public class SpringCacheBasedTicketCache implements StatelessTicketCache { + + private static final Log logger = LogFactory.getLog(SpringCacheBasedTicketCache.class); + + private final Cache cache; + + public SpringCacheBasedTicketCache(Cache cache) { + Assert.notNull(cache, "cache mandatory"); + this.cache = cache; + } + + @Override + public CasAuthenticationToken getByTicketId(final String serviceTicket) { + final Cache.ValueWrapper element = (serviceTicket != null) ? this.cache.get(serviceTicket) : null; + logger.debug(LogMessage.of(() -> "Cache hit: " + (element != null) + "; service ticket: " + serviceTicket)); + return (element != null) ? (CasAuthenticationToken) element.get() : null; + } + + @Override + public void putTicketInCache(final CasAuthenticationToken token) { + String key = token.getCredentials().toString(); + logger.debug(LogMessage.of(() -> "Cache put: " + key)); + this.cache.put(key, token); + } + + @Override + public void removeTicketFromCache(final CasAuthenticationToken token) { + logger.debug(LogMessage.of(() -> "Cache remove: " + token.getCredentials().toString())); + this.removeTicketFromCache(token.getCredentials().toString()); + } + + @Override + public void removeTicketFromCache(final String serviceTicket) { + this.cache.evict(serviceTicket); + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/StatelessTicketCache.java b/cas/src/main/java/org/springframework/security/cas/authentication/StatelessTicketCache.java new file mode 100644 index 0000000000..74df6bb9df --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/StatelessTicketCache.java @@ -0,0 +1,110 @@ +/* + * Copyright 2004 Acegi Technology Pty Limited + * + * 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.authentication; + +/** + * Caches CAS service tickets and CAS proxy tickets for stateless connections. + * + *

+ * When a service ticket or proxy ticket is validated against the CAS server, it is unable + * to be used again. Most types of callers are stateful and are associated with a given + * HttpSession. This allows the affirmative CAS validation outcome to be + * stored in the HttpSession, meaning the removal of the ticket from the CAS + * server is not an issue. + *

+ * + *

+ * Stateless callers, such as remoting protocols, cannot take advantage of + * HttpSession. If the stateless caller is located a significant network + * distance from the CAS server, acquiring a fresh service ticket or proxy ticket for each + * invocation would be expensive. + *

+ * + *

+ * To avoid this issue with stateless callers, it is expected stateless callers will + * obtain a single service ticket or proxy ticket, and then present this same ticket to + * the Spring Security secured application on each occasion. As no + * HttpSession is available for such callers, the affirmative CAS validation + * outcome cannot be stored in this location. + *

+ * + *

+ * The StatelessTicketCache enables the service tickets and proxy tickets + * belonging to stateless callers to be placed in a cache. This in-memory cache stores the + * CasAuthenticationToken, effectively providing the same capability as a + * HttpSession with the ticket identifier being the key rather than a session + * identifier. + *

+ * + *

+ * Implementations should provide a reasonable timeout on stored entries, such that the + * stateless caller are not required to unnecessarily acquire fresh CAS service tickets or + * proxy tickets. + *

+ * + * @author Ben Alex + */ +public interface StatelessTicketCache { + + /** + * Retrieves the CasAuthenticationToken associated with the specified + * ticket. + * + *

+ * If not found, returns a nullCasAuthenticationToken. + *

+ * @return the fully populated authentication token + */ + CasAuthenticationToken getByTicketId(String serviceTicket); + + /** + * Adds the specified CasAuthenticationToken to the cache. + * + *

+ * The {@link CasAuthenticationToken#getCredentials()} method is used to retrieve the + * service ticket number. + *

+ * @param token to be added to the cache + */ + void putTicketInCache(CasAuthenticationToken token); + + /** + * Removes the specified ticket from the cache, as per + * {@link #removeTicketFromCache(String)}. + * + *

+ * Implementations should use {@link CasAuthenticationToken#getCredentials()} to + * obtain the ticket and then delegate to the {@link #removeTicketFromCache(String)} + * method. + *

+ * @param token to be removed + */ + void removeTicketFromCache(CasAuthenticationToken token); + + /** + * Removes the specified ticket from the cache, meaning that future calls will require + * a new service ticket. + * + *

+ * This is in case applications wish to provide a session termination capability for + * their stateless clients. + *

+ * @param serviceTicket to be removed + */ + void removeTicketFromCache(String serviceTicket); + +} diff --git a/cas/src/main/java/org/springframework/security/cas/authentication/package-info.java b/cas/src/main/java/org/springframework/security/cas/authentication/package-info.java new file mode 100644 index 0000000000..8803500a0a --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/authentication/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2002-2016 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. + */ + +/** + * An {@code AuthenticationProvider} that can process CAS service tickets and proxy + * tickets. + */ +package org.springframework.security.cas.authentication; diff --git a/cas/src/main/java/org/springframework/security/cas/jackson2/AssertionImplMixin.java b/cas/src/main/java/org/springframework/security/cas/jackson2/AssertionImplMixin.java new file mode 100644 index 0000000000..f3d7de8c02 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/jackson2/AssertionImplMixin.java @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2016 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.jackson2; + +import java.util.Date; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.jasig.cas.client.authentication.AttributePrincipal; + +/** + * Helps in jackson deserialization of class + * {@link org.jasig.cas.client.validation.AssertionImpl}, which is used with + * {@link org.springframework.security.cas.authentication.CasAuthenticationToken}. To use + * this class we need to register with + * {@link com.fasterxml.jackson.databind.ObjectMapper}. Type information will be stored + * in @class property. + *

+ *

+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new CasJackson2Module());
+ * 
+ * + * @author Jitendra Singh + * @since 4.2 + * @see CasJackson2Module + * @see org.springframework.security.jackson2.SecurityJackson2Modules + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +class AssertionImplMixin { + + /** + * Mixin Constructor helps in deserialize + * {@link org.jasig.cas.client.validation.AssertionImpl} + * @param principal the Principal to associate with the Assertion. + * @param validFromDate when the assertion is valid from. + * @param validUntilDate when the assertion is valid to. + * @param authenticationDate when the assertion is authenticated. + * @param attributes the key/value pairs for this attribute. + */ + @JsonCreator + AssertionImplMixin(@JsonProperty("principal") AttributePrincipal principal, + @JsonProperty("validFromDate") Date validFromDate, @JsonProperty("validUntilDate") Date validUntilDate, + @JsonProperty("authenticationDate") Date authenticationDate, + @JsonProperty("attributes") Map attributes) { + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/jackson2/AttributePrincipalImplMixin.java b/cas/src/main/java/org/springframework/security/cas/jackson2/AttributePrincipalImplMixin.java new file mode 100644 index 0000000000..0ec671fb55 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/jackson2/AttributePrincipalImplMixin.java @@ -0,0 +1,66 @@ +/* + * Copyright 2015-2016 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.jackson2; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.jasig.cas.client.proxy.ProxyRetriever; + +/** + * Helps in deserialize {@link org.jasig.cas.client.authentication.AttributePrincipalImpl} + * which is used with + * {@link org.springframework.security.cas.authentication.CasAuthenticationToken}. Type + * information will be stored in property named @class. + *

+ *

+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new CasJackson2Module());
+ * 
+ * + * @author Jitendra Singh + * @since 4.2 + * @see CasJackson2Module + * @see org.springframework.security.jackson2.SecurityJackson2Modules + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +class AttributePrincipalImplMixin { + + /** + * Mixin Constructor helps in deserialize + * {@link org.jasig.cas.client.authentication.AttributePrincipalImpl} + * @param name the unique identifier for the principal. + * @param attributes the key/value pairs for this principal. + * @param proxyGrantingTicket the ticket associated with this principal. + * @param proxyRetriever the ProxyRetriever implementation to call back to the CAS + * server. + */ + @JsonCreator + AttributePrincipalImplMixin(@JsonProperty("name") String name, + @JsonProperty("attributes") Map attributes, + @JsonProperty("proxyGrantingTicket") String proxyGrantingTicket, + @JsonProperty("proxyRetriever") ProxyRetriever proxyRetriever) { + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/jackson2/CasAuthenticationTokenMixin.java b/cas/src/main/java/org/springframework/security/cas/jackson2/CasAuthenticationTokenMixin.java new file mode 100644 index 0000000000..80e40a0dca --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/jackson2/CasAuthenticationTokenMixin.java @@ -0,0 +1,83 @@ +/* + * Copyright 2015-2016 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.jackson2; + +import java.util.Collection; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.jasig.cas.client.validation.Assertion; + +import org.springframework.security.cas.authentication.CasAuthenticationProvider; +import org.springframework.security.cas.authentication.CasAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * Mixin class which helps in deserialize + * {@link org.springframework.security.cas.authentication.CasAuthenticationToken} using + * jackson. Two more dependent classes needs to register along with this mixin class. + *
    + *
  1. {@link org.springframework.security.cas.jackson2.AssertionImplMixin}
  2. + *
  3. {@link org.springframework.security.cas.jackson2.AttributePrincipalImplMixin}
  4. + *
+ * + *

+ * + *

+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new CasJackson2Module());
+ * 
+ * + * @author Jitendra Singh + * @since 4.2 + * @see CasJackson2Module + * @see org.springframework.security.jackson2.SecurityJackson2Modules + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, isGetterVisibility = JsonAutoDetect.Visibility.NONE, + getterVisibility = JsonAutoDetect.Visibility.NONE, creatorVisibility = JsonAutoDetect.Visibility.ANY) +@JsonIgnoreProperties(ignoreUnknown = true) +class CasAuthenticationTokenMixin { + + /** + * Mixin Constructor helps in deserialize {@link CasAuthenticationToken} + * @param keyHash hashCode of provided key to identify if this object made by a given + * {@link CasAuthenticationProvider} + * @param principal typically the UserDetails object (cannot be null) + * @param credentials the service/proxy ticket ID from CAS (cannot be + * null) + * @param authorities the authorities granted to the user (from the + * {@link org.springframework.security.core.userdetails.UserDetailsService}) (cannot + * be null) + * @param userDetails the user details (from the + * {@link org.springframework.security.core.userdetails.UserDetailsService}) (cannot + * be null) + * @param assertion the assertion returned from the CAS servers. It contains the + * principal and how to obtain a proxy ticket for the user. + */ + @JsonCreator + CasAuthenticationTokenMixin(@JsonProperty("keyHash") Integer keyHash, @JsonProperty("principal") Object principal, + @JsonProperty("credentials") Object credentials, + @JsonProperty("authorities") Collection authorities, + @JsonProperty("userDetails") UserDetails userDetails, @JsonProperty("assertion") Assertion assertion) { + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java b/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java new file mode 100644 index 0000000000..34f19ca10a --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2016 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.jackson2; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.jasig.cas.client.authentication.AttributePrincipalImpl; +import org.jasig.cas.client.validation.AssertionImpl; + +import org.springframework.security.cas.authentication.CasAuthenticationToken; +import org.springframework.security.jackson2.SecurityJackson2Modules; + +/** + * Jackson module for spring-security-cas. This module register + * {@link AssertionImplMixin}, {@link AttributePrincipalImplMixin} and + * {@link CasAuthenticationTokenMixin}. If no default typing enabled by default then it'll + * enable it because typing info is needed to properly serialize/deserialize objects. In + * order to use this module just add this module into your ObjectMapper configuration. + * + *
+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new CasJackson2Module());
+ * 
Note: use {@link SecurityJackson2Modules#getModules(ClassLoader)} to get list + * of all security modules on the classpath. + * + * @author Jitendra Singh. + * @since 4.2 + * @see org.springframework.security.jackson2.SecurityJackson2Modules + */ +public class CasJackson2Module extends SimpleModule { + + public CasJackson2Module() { + super(CasJackson2Module.class.getName(), new Version(1, 0, 0, null, null, null)); + } + + @Override + public void setupModule(SetupContext context) { + SecurityJackson2Modules.enableDefaultTyping(context.getOwner()); + context.setMixInAnnotations(AssertionImpl.class, AssertionImplMixin.class); + context.setMixInAnnotations(AttributePrincipalImpl.class, AttributePrincipalImplMixin.class); + context.setMixInAnnotations(CasAuthenticationToken.class, CasAuthenticationTokenMixin.class); + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/package-info.java b/cas/src/main/java/org/springframework/security/cas/package-info.java new file mode 100644 index 0000000000..13fae9057d --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2002-2016 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. + */ + +/** + * Spring Security support for Jasig's Central Authentication Service + * (CAS). + */ +package org.springframework.security.cas; diff --git a/cas/src/main/java/org/springframework/security/cas/userdetails/AbstractCasAssertionUserDetailsService.java b/cas/src/main/java/org/springframework/security/cas/userdetails/AbstractCasAssertionUserDetailsService.java new file mode 100644 index 0000000000..3d8cd9e412 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/userdetails/AbstractCasAssertionUserDetailsService.java @@ -0,0 +1,51 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.userdetails; + +import org.jasig.cas.client.validation.Assertion; + +import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * Abstract class for using the provided CAS assertion to construct a new User object. + * This generally is most useful when combined with a SAML-based response from the CAS + * Server/client. + * + * @author Scott Battaglia + * @since 3.0 + */ +public abstract class AbstractCasAssertionUserDetailsService + implements AuthenticationUserDetailsService { + + @Override + public final UserDetails loadUserDetails(final CasAssertionAuthenticationToken token) { + return loadUserDetails(token.getAssertion()); + } + + /** + * Protected template method for construct a + * {@link org.springframework.security.core.userdetails.UserDetails} via the supplied + * CAS assertion. + * @param assertion the assertion to use to construct the new UserDetails. CANNOT be + * NULL. + * @return the newly constructed UserDetails. + */ + protected abstract UserDetails loadUserDetails(Assertion assertion); + +} diff --git a/cas/src/main/java/org/springframework/security/cas/userdetails/GrantedAuthorityFromAssertionAttributesUserDetailsService.java b/cas/src/main/java/org/springframework/security/cas/userdetails/GrantedAuthorityFromAssertionAttributesUserDetailsService.java new file mode 100644 index 0000000000..0e47d1c57f --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/userdetails/GrantedAuthorityFromAssertionAttributesUserDetailsService.java @@ -0,0 +1,87 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.userdetails; + +import java.util.ArrayList; +import java.util.List; + +import org.jasig.cas.client.validation.Assertion; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.Assert; + +/** + * Populates the {@link org.springframework.security.core.GrantedAuthority}s for a user by + * reading a list of attributes that were returned as part of the CAS response. Each + * attribute is read and each value of the attribute is turned into a GrantedAuthority. If + * the attribute has no value then its not added. + * + * @author Scott Battaglia + * @since 3.0 + */ +public final class GrantedAuthorityFromAssertionAttributesUserDetailsService + extends AbstractCasAssertionUserDetailsService { + + private static final String NON_EXISTENT_PASSWORD_VALUE = "NO_PASSWORD"; + + private final String[] attributes; + + private boolean convertToUpperCase = true; + + public GrantedAuthorityFromAssertionAttributesUserDetailsService(final String[] attributes) { + Assert.notNull(attributes, "attributes cannot be null."); + Assert.isTrue(attributes.length > 0, "At least one attribute is required to retrieve roles from."); + this.attributes = attributes; + } + + @SuppressWarnings("unchecked") + @Override + protected UserDetails loadUserDetails(final Assertion assertion) { + List grantedAuthorities = new ArrayList<>(); + for (String attribute : this.attributes) { + Object value = assertion.getPrincipal().getAttributes().get(attribute); + if (value != null) { + if (value instanceof List) { + for (Object o : (List) value) { + grantedAuthorities.add(createSimpleGrantedAuthority(o)); + } + } + else { + grantedAuthorities.add(createSimpleGrantedAuthority(value)); + } + } + } + return new User(assertion.getPrincipal().getName(), NON_EXISTENT_PASSWORD_VALUE, true, true, true, true, + grantedAuthorities); + } + + private SimpleGrantedAuthority createSimpleGrantedAuthority(Object o) { + return new SimpleGrantedAuthority(this.convertToUpperCase ? o.toString().toUpperCase() : o.toString()); + } + + /** + * Converts the returned attribute values to uppercase values. + * @param convertToUpperCase true if it should convert, false otherwise. + */ + public void setConvertToUpperCase(final boolean convertToUpperCase) { + this.convertToUpperCase = convertToUpperCase; + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationEntryPoint.java b/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationEntryPoint.java new file mode 100644 index 0000000000..18ecb23623 --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationEntryPoint.java @@ -0,0 +1,150 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.jasig.cas.client.util.CommonUtils; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.util.Assert; + +/** + * Used by the ExceptionTranslationFilter to commence authentication via the + * JA-SIG Central Authentication Service (CAS). + *

+ * The user's browser will be redirected to the JA-SIG CAS enterprise-wide login page. + * This page is specified by the loginUrl property. Once login is complete, + * the CAS login page will redirect to the page indicated by the service + * property. The service is a HTTP URL belonging to the current application. + * The service URL is monitored by the {@link CasAuthenticationFilter}, which + * will validate the CAS login was successful. + * + * @author Ben Alex + * @author Scott Battaglia + */ +public class CasAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean { + + private ServiceProperties serviceProperties; + + private String loginUrl; + + /** + * Determines whether the Service URL should include the session id for the specific + * user. As of CAS 3.0.5, the session id will automatically be stripped. However, + * older versions of CAS (i.e. CAS 2), do not automatically strip the session + * identifier (this is a bug on the part of the older server implementations), so an + * option to disable the session encoding is provided for backwards compatibility. + * + * By default, encoding is enabled. + */ + private boolean encodeServiceUrlWithSessionId = true; + + @Override + public void afterPropertiesSet() { + Assert.hasLength(this.loginUrl, "loginUrl must be specified"); + Assert.notNull(this.serviceProperties, "serviceProperties must be specified"); + Assert.notNull(this.serviceProperties.getService(), "serviceProperties.getService() cannot be null."); + } + + @Override + public final void commence(final HttpServletRequest servletRequest, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + String urlEncodedService = createServiceUrl(servletRequest, response); + String redirectUrl = createRedirectUrl(urlEncodedService); + preCommence(servletRequest, response); + response.sendRedirect(redirectUrl); + } + + /** + * Constructs a new Service Url. The default implementation relies on the CAS client + * to do the bulk of the work. + * @param request the HttpServletRequest + * @param response the HttpServlet Response + * @return the constructed service url. CANNOT be NULL. + */ + protected String createServiceUrl(HttpServletRequest request, HttpServletResponse response) { + return CommonUtils.constructServiceUrl(null, response, this.serviceProperties.getService(), null, + this.serviceProperties.getArtifactParameter(), this.encodeServiceUrlWithSessionId); + } + + /** + * Constructs the Url for Redirection to the CAS server. Default implementation relies + * on the CAS client to do the bulk of the work. + * @param serviceUrl the service url that should be included. + * @return the redirect url. CANNOT be NULL. + */ + protected String createRedirectUrl(String serviceUrl) { + return CommonUtils.constructRedirectUrl(this.loginUrl, this.serviceProperties.getServiceParameter(), serviceUrl, + this.serviceProperties.isSendRenew(), false); + } + + /** + * Template method for you to do your own pre-processing before the redirect occurs. + * @param request the HttpServletRequest + * @param response the HttpServletResponse + */ + protected void preCommence(HttpServletRequest request, HttpServletResponse response) { + + } + + /** + * The enterprise-wide CAS login URL. Usually something like + * https://www.mycompany.com/cas/login. + * @return the enterprise-wide CAS login URL + */ + public final String getLoginUrl() { + return this.loginUrl; + } + + public final ServiceProperties getServiceProperties() { + return this.serviceProperties; + } + + public final void setLoginUrl(String loginUrl) { + this.loginUrl = loginUrl; + } + + public final void setServiceProperties(ServiceProperties serviceProperties) { + this.serviceProperties = serviceProperties; + } + + /** + * Sets whether to encode the service url with the session id or not. + * @param encodeServiceUrlWithSessionId whether to encode the service url with the + * session id or not. + */ + public final void setEncodeServiceUrlWithSessionId(boolean encodeServiceUrlWithSessionId) { + this.encodeServiceUrlWithSessionId = encodeServiceUrlWithSessionId; + } + + /** + * Sets whether to encode the service url with the session id or not. + * @return whether to encode the service url with the session id or not. + * + */ + protected boolean getEncodeServiceUrlWithSessionId() { + return this.encodeServiceUrlWithSessionId; + } + +} 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 new file mode 100644 index 0000000000..8e8b84700f --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/CasAuthenticationFilter.java @@ -0,0 +1,397 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.jasig.cas.client.proxy.ProxyGrantingTicketStorage; +import org.jasig.cas.client.util.CommonUtils; +import org.jasig.cas.client.validation.TicketValidator; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails; +import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetailsSource; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +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.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * Processes a CAS service ticket, obtains proxy granting tickets, and processes proxy + * tickets. + *

Service Tickets

+ *

+ * A service ticket consists of an opaque ticket string. It arrives at this filter by the + * user's browser successfully authenticating using CAS, and then receiving a HTTP + * redirect to a service. The opaque ticket string is presented in the + * ticket request parameter. + *

+ * This filter monitors the service URL so it can receive the service ticket + * and process it. By default this filter processes the URL /login/cas. When + * processing this URL, the value of {@link ServiceProperties#getService()} is used as the + * service when validating the ticket. This means that it is + * important that {@link ServiceProperties#getService()} specifies the same value as the + * filterProcessesUrl. + *

+ * Processing the service ticket involves creating a + * UsernamePasswordAuthenticationToken which uses + * {@link #CAS_STATEFUL_IDENTIFIER} for the principal and the opaque ticket + * string as the credentials. + *

Obtaining Proxy Granting Tickets

+ *

+ * If specified, the filter can also monitor the proxyReceptorUrl. The filter + * will respond to requests matching this url so that the CAS Server can provide a PGT to + * the filter. Note that in addition to the proxyReceptorUrl a non-null + * proxyGrantingTicketStorage must be provided in order for the filter to + * respond to proxy receptor requests. By configuring a shared + * {@link ProxyGrantingTicketStorage} between the {@link TicketValidator} and the + * CasAuthenticationFilter one can have the CasAuthenticationFilter handle the proxying + * requirements for CAS. + *

Proxy Tickets

+ *

+ * The filter can process tickets present on any url. This is useful when wanting to + * process proxy tickets. In order for proxy tickets to get processed + * {@link ServiceProperties#isAuthenticateAllArtifacts()} must return true. + * Additionally, if the request is already authenticated, authentication will not + * occur. Last, {@link AuthenticationDetailsSource#buildDetails(Object)} must return a + * {@link ServiceAuthenticationDetails}. This can be accomplished using the + * {@link ServiceAuthenticationDetailsSource}. In this case + * {@link ServiceAuthenticationDetails#getServiceUrl()} will be used for the service url. + *

+ * Processing the proxy ticket involves creating a + * UsernamePasswordAuthenticationToken which uses + * {@link #CAS_STATELESS_IDENTIFIER} for the principal and the opaque ticket + * string as the credentials. When a proxy ticket is successfully + * authenticated, the FilterChain continues and the + * authenticationSuccessHandler is not used. + *

Notes about the AuthenticationManager

+ *

+ * The configured AuthenticationManager is expected to provide a provider + * that can recognise UsernamePasswordAuthenticationTokens containing this + * special principal name, and process them accordingly by validation with + * the CAS server. Additionally, it should be capable of using the result of + * {@link ServiceAuthenticationDetails#getServiceUrl()} as the service when validating the + * ticket. + *

Example Configuration

+ *

+ * An example configuration that supports service tickets, obtaining proxy granting + * tickets, and proxy tickets is illustrated below: + * + *

+ * <b:bean id="serviceProperties"
+ *     class="org.springframework.security.cas.ServiceProperties"
+ *     p:service="https://service.example.com/cas-sample/login/cas"
+ *     p:authenticateAllArtifacts="true"/>
+ * <b:bean id="casEntryPoint"
+ *     class="org.springframework.security.cas.web.CasAuthenticationEntryPoint"
+ *     p:serviceProperties-ref="serviceProperties" p:loginUrl="https://login.example.org/cas/login" />
+ * <b:bean id="casFilter"
+ *     class="org.springframework.security.cas.web.CasAuthenticationFilter"
+ *     p:authenticationManager-ref="authManager"
+ *     p:serviceProperties-ref="serviceProperties"
+ *     p:proxyGrantingTicketStorage-ref="pgtStorage"
+ *     p:proxyReceptorUrl="/login/cas/proxyreceptor">
+ *     <b:property name="authenticationDetailsSource">
+ *         <b:bean class="org.springframework.security.cas.web.authentication.ServiceAuthenticationDetailsSource"/>
+ *     </b:property>
+ *     <b:property name="authenticationFailureHandler">
+ *         <b:bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler"
+ *             p:defaultFailureUrl="/casfailed.jsp"/>
+ *     </b:property>
+ * </b:bean>
+ * <!--
+ *     NOTE: In a real application you should not use an in memory implementation. You will also want
+ *           to ensure to clean up expired tickets by calling ProxyGrantingTicketStorage.cleanup()
+ *  -->
+ * <b:bean id="pgtStorage" class="org.jasig.cas.client.proxy.ProxyGrantingTicketStorageImpl"/>
+ * <b:bean id="casAuthProvider" class="org.springframework.security.cas.authentication.CasAuthenticationProvider"
+ *     p:serviceProperties-ref="serviceProperties"
+ *     p:key="casAuthProviderKey">
+ *     <b:property name="authenticationUserDetailsService">
+ *         <b:bean
+ *             class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
+ *             <b:constructor-arg ref="userService" />
+ *         </b:bean>
+ *     </b:property>
+ *     <b:property name="ticketValidator">
+ *         <b:bean
+ *             class="org.jasig.cas.client.validation.Cas20ProxyTicketValidator"
+ *             p:acceptAnyProxy="true"
+ *             p:proxyCallbackUrl="https://service.example.com/cas-sample/login/cas/proxyreceptor"
+ *             p:proxyGrantingTicketStorage-ref="pgtStorage">
+ *             <b:constructor-arg value="https://login.example.org/cas" />
+ *         </b:bean>
+ *     </b:property>
+ *     <b:property name="statelessTicketCache">
+ *         <b:bean class="org.springframework.security.cas.authentication.EhCacheBasedTicketCache">
+ *             <b:property name="cache">
+ *                 <b:bean class="net.sf.ehcache.Cache"
+ *                   init-method="initialise"
+ *                   destroy-method="dispose">
+ *                     <b:constructor-arg value="casTickets"/>
+ *                     <b:constructor-arg value="50"/>
+ *                     <b:constructor-arg value="true"/>
+ *                     <b:constructor-arg value="false"/>
+ *                     <b:constructor-arg value="3600"/>
+ *                     <b:constructor-arg value="900"/>
+ *                 </b:bean>
+ *             </b:property>
+ *         </b:bean>
+ *     </b:property>
+ * </b:bean>
+ * 
+ * + * @author Ben Alex + * @author Rob Winch + */ +public class CasAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + /** + * Used to identify a CAS request for a stateful user agent, such as a web browser. + */ + public static final String CAS_STATEFUL_IDENTIFIER = "_cas_stateful_"; + + /** + * Used to identify a CAS request for a stateless user agent, such as a remoting + * protocol client (e.g. Hessian, Burlap, SOAP etc). Results in a more aggressive + * caching strategy being used, as the absence of a HttpSession will + * result in a new authentication attempt on every request. + */ + public static final String CAS_STATELESS_IDENTIFIER = "_cas_stateless_"; + + /** + * The last portion of the receptor url, i.e. /proxy/receptor + */ + private RequestMatcher proxyReceptorMatcher; + + /** + * The backing storage to store ProxyGrantingTicket requests. + */ + private ProxyGrantingTicketStorage proxyGrantingTicketStorage; + + private String artifactParameter = ServiceProperties.DEFAULT_CAS_ARTIFACT_PARAMETER; + + private boolean authenticateAllArtifacts; + + private AuthenticationFailureHandler proxyFailureHandler = new SimpleUrlAuthenticationFailureHandler(); + + public CasAuthenticationFilter() { + super("/login/cas"); + setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler()); + } + + @Override + protected final void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain chain, Authentication authResult) throws IOException, ServletException { + boolean continueFilterChain = proxyTicketRequest(serviceTicketRequest(request, response), request); + if (!continueFilterChain) { + super.successfulAuthentication(request, response, chain, authResult); + return; + } + this.logger.debug( + LogMessage.format("Authentication success. Updating SecurityContextHolder to contain: %s", authResult)); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authResult); + SecurityContextHolder.setContext(context); + if (this.eventPublisher != null) { + this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass())); + } + chain.doFilter(request, response); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException { + // if the request is a proxy request process it and return null to indicate the + // request has been processed + if (proxyReceptorRequest(request)) { + this.logger.debug("Responding to proxy receptor request"); + CommonUtils.readAndRespondToProxyReceptorRequest(request, response, this.proxyGrantingTicketStorage); + return null; + } + boolean serviceTicketRequest = serviceTicketRequest(request, response); + String username = serviceTicketRequest ? CAS_STATEFUL_IDENTIFIER : CAS_STATELESS_IDENTIFIER; + String password = obtainArtifact(request); + if (password == null) { + this.logger.debug("Failed to obtain an artifact (cas ticket)"); + password = ""; + } + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); + authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); + return this.getAuthenticationManager().authenticate(authRequest); + } + + /** + * If present, gets the artifact (CAS ticket) from the {@link HttpServletRequest}. + * @param request + * @return if present the artifact from the {@link HttpServletRequest}, else null + */ + protected String obtainArtifact(HttpServletRequest request) { + return request.getParameter(this.artifactParameter); + } + + /** + * Overridden to provide proxying capabilities. + */ + @Override + protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { + final boolean serviceTicketRequest = serviceTicketRequest(request, response); + final boolean result = serviceTicketRequest || proxyReceptorRequest(request) + || (proxyTicketRequest(serviceTicketRequest, request)); + if (this.logger.isDebugEnabled()) { + this.logger.debug("requiresAuthentication = " + result); + } + return result; + } + + /** + * Sets the {@link AuthenticationFailureHandler} for proxy requests. + * @param proxyFailureHandler + */ + public final void setProxyAuthenticationFailureHandler(AuthenticationFailureHandler proxyFailureHandler) { + Assert.notNull(proxyFailureHandler, "proxyFailureHandler cannot be null"); + this.proxyFailureHandler = proxyFailureHandler; + } + + /** + * Wraps the {@link AuthenticationFailureHandler} to distinguish between handling + * proxy ticket authentication failures and service ticket failures. + */ + @Override + public final void setAuthenticationFailureHandler(AuthenticationFailureHandler failureHandler) { + super.setAuthenticationFailureHandler(new CasAuthenticationFailureHandler(failureHandler)); + } + + public final void setProxyReceptorUrl(final String proxyReceptorUrl) { + this.proxyReceptorMatcher = new AntPathRequestMatcher("/**" + proxyReceptorUrl); + } + + public final void setProxyGrantingTicketStorage(final ProxyGrantingTicketStorage proxyGrantingTicketStorage) { + this.proxyGrantingTicketStorage = proxyGrantingTicketStorage; + } + + public final void setServiceProperties(final ServiceProperties serviceProperties) { + this.artifactParameter = serviceProperties.getArtifactParameter(); + this.authenticateAllArtifacts = serviceProperties.isAuthenticateAllArtifacts(); + } + + /** + * Indicates if the request is elgible to process a service ticket. This method exists + * for readability. + * @param request + * @param response + * @return + */ + private boolean serviceTicketRequest(HttpServletRequest request, HttpServletResponse response) { + boolean result = super.requiresAuthentication(request, response); + this.logger.debug(LogMessage.format("serviceTicketRequest = %s", result)); + return result; + } + + /** + * Indicates if the request is elgible to process a proxy ticket. + * @param request + * @return + */ + private boolean proxyTicketRequest(boolean serviceTicketRequest, HttpServletRequest request) { + if (serviceTicketRequest) { + return false; + } + boolean result = this.authenticateAllArtifacts && obtainArtifact(request) != null && !authenticated(); + this.logger.debug(LogMessage.format("proxyTicketRequest = %s", result)); + return result; + } + + /** + * Determines if a user is already authenticated. + * @return + */ + private boolean authenticated() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && authentication.isAuthenticated() + && !(authentication instanceof AnonymousAuthenticationToken); + } + + /** + * Indicates if the request is elgible to be processed as the proxy receptor. + * @param request + * @return + */ + private boolean proxyReceptorRequest(HttpServletRequest request) { + final boolean result = proxyReceptorConfigured() && this.proxyReceptorMatcher.matches(request); + this.logger.debug(LogMessage.format("proxyReceptorRequest = %s", result)); + return result; + } + + /** + * Determines if the {@link CasAuthenticationFilter} is configured to handle the proxy + * receptor requests. + * @return + */ + private boolean proxyReceptorConfigured() { + final boolean result = this.proxyGrantingTicketStorage != null && this.proxyReceptorMatcher != null; + this.logger.debug(LogMessage.format("proxyReceptorConfigured = %s", result)); + return result; + } + + /** + * A wrapper for the AuthenticationFailureHandler that will flex the + * {@link AuthenticationFailureHandler} that is used. The value + * {@link CasAuthenticationFilter#setProxyAuthenticationFailureHandler(AuthenticationFailureHandler)} + * will be used for proxy requests that fail. The value + * {@link CasAuthenticationFilter#setAuthenticationFailureHandler(AuthenticationFailureHandler)} + * will be used for service tickets that fail. + */ + private class CasAuthenticationFailureHandler implements AuthenticationFailureHandler { + + private final AuthenticationFailureHandler serviceTicketFailureHandler; + + CasAuthenticationFailureHandler(AuthenticationFailureHandler failureHandler) { + Assert.notNull(failureHandler, "failureHandler"); + this.serviceTicketFailureHandler = failureHandler; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + if (serviceTicketRequest(request, response)) { + this.serviceTicketFailureHandler.onAuthenticationFailure(request, response, exception); + } + else { + CasAuthenticationFilter.this.proxyFailureHandler.onAuthenticationFailure(request, response, exception); + } + } + + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetails.java b/cas/src/main/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetails.java new file mode 100644 index 0000000000..c550e984ea --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetails.java @@ -0,0 +1,146 @@ +/* + * Copyright 2011-2016 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.authentication; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.regex.Pattern; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.security.web.util.UrlUtils; +import org.springframework.util.Assert; + +/** + * A default implementation of {@link ServiceAuthenticationDetails} that figures out the + * value for {@link #getServiceUrl()} by inspecting the current {@link HttpServletRequest} + * and using the current URL minus the artifact and the corresponding value. + * + * @author Rob Winch + */ +final class DefaultServiceAuthenticationDetails extends WebAuthenticationDetails + implements ServiceAuthenticationDetails { + + private static final long serialVersionUID = 6192409090610517700L; + + private final String serviceUrl; + + /** + * Creates a new instance + * @param request the current {@link HttpServletRequest} to obtain the + * {@link #getServiceUrl()} from. + * @param artifactPattern the {@link Pattern} that will be used to clean up the query + * string from containing the artifact name and value. This can be created using + * {@link #createArtifactPattern(String)}. + */ + DefaultServiceAuthenticationDetails(String casService, HttpServletRequest request, Pattern artifactPattern) + throws MalformedURLException { + super(request); + URL casServiceUrl = new URL(casService); + int port = getServicePort(casServiceUrl); + final String query = getQueryString(request, artifactPattern); + this.serviceUrl = UrlUtils.buildFullRequestUrl(casServiceUrl.getProtocol(), casServiceUrl.getHost(), port, + request.getRequestURI(), query); + } + + /** + * Returns the current URL minus the artifact parameter and its value, if present. + * @see org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails#getServiceUrl() + */ + @Override + public String getServiceUrl() { + return this.serviceUrl; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj) || !(obj instanceof DefaultServiceAuthenticationDetails)) { + return false; + } + ServiceAuthenticationDetails that = (ServiceAuthenticationDetails) obj; + return this.serviceUrl.equals(that.getServiceUrl()); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + this.serviceUrl.hashCode(); + return result; + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder(); + result.append(super.toString()); + result.append("ServiceUrl: "); + result.append(this.serviceUrl); + return result.toString(); + } + + /** + * If present, removes the artifactParameterName and the corresponding value from the + * query String. + * @param request + * @return the query String minus the artifactParameterName and the corresponding + * value. + */ + private String getQueryString(final HttpServletRequest request, final Pattern artifactPattern) { + final String query = request.getQueryString(); + if (query == null) { + return null; + } + String result = artifactPattern.matcher(query).replaceFirst(""); + if (result.length() == 0) { + return null; + } + // strip off the trailing & only if the artifact was the first query param + return result.startsWith("&") ? result.substring(1) : result; + } + + /** + * Creates a {@link Pattern} that can be passed into the constructor. This allows the + * {@link Pattern} to be reused for every instance of + * {@link DefaultServiceAuthenticationDetails}. + * @param artifactParameterName + * @return + */ + static Pattern createArtifactPattern(String artifactParameterName) { + Assert.hasLength(artifactParameterName, "artifactParameterName is expected to have a length"); + return Pattern.compile("&?" + Pattern.quote(artifactParameterName) + "=[^&]*"); + } + + /** + * Gets the port from the casServiceURL ensuring to return the proper value if the + * default port is being used. + * @param casServiceUrl the casServerUrl to be used (i.e. + * "https://example.com/context/login/cas") + * @return the port that is configured for the casServerUrl + */ + private static int getServicePort(URL casServiceUrl) { + int port = casServiceUrl.getPort(); + if (port == -1) { + port = casServiceUrl.getDefaultPort(); + } + return port; + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/web/authentication/ServiceAuthenticationDetails.java b/cas/src/main/java/org/springframework/security/cas/web/authentication/ServiceAuthenticationDetails.java new file mode 100644 index 0000000000..e14da3d70e --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/authentication/ServiceAuthenticationDetails.java @@ -0,0 +1,42 @@ +/* + * Copyright 2011-2016 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.authentication; + +import java.io.Serializable; + +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.cas.authentication.CasAuthenticationProvider; +import org.springframework.security.core.Authentication; + +/** + * In order for the {@link CasAuthenticationProvider} to provide the correct service url + * to authenticate the ticket, the returned value of {@link Authentication#getDetails()} + * should implement this interface when tickets can be sent to any URL rather than only + * {@link ServiceProperties#getService()}. + * + * @author Rob Winch + * @see ServiceAuthenticationDetailsSource + */ +public interface ServiceAuthenticationDetails extends Serializable { + + /** + * Gets the absolute service url (i.e. https://example.com/service/). + * @return the service url. Cannot be null. + */ + String getServiceUrl(); + +} diff --git a/cas/src/main/java/org/springframework/security/cas/web/authentication/ServiceAuthenticationDetailsSource.java b/cas/src/main/java/org/springframework/security/cas/web/authentication/ServiceAuthenticationDetailsSource.java new file mode 100644 index 0000000000..b8515892dc --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/authentication/ServiceAuthenticationDetailsSource.java @@ -0,0 +1,83 @@ +/* + * Copyright 2011-2016 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.authentication; + +import java.net.MalformedURLException; +import java.util.regex.Pattern; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.util.Assert; + +/** + * The {@code AuthenticationDetailsSource} that is set on the + * {@code CasAuthenticationFilter} should return a value that implements + * {@code ServiceAuthenticationDetails} if the application needs to authenticate dynamic + * service urls. The + * {@code ServiceAuthenticationDetailsSource#buildDetails(HttpServletRequest)} creates a + * default {@code ServiceAuthenticationDetails}. + * + * @author Rob Winch + */ +public class ServiceAuthenticationDetailsSource + implements AuthenticationDetailsSource { + + private final Pattern artifactPattern; + + private ServiceProperties serviceProperties; + + /** + * Creates an implementation that uses the specified ServiceProperties and the default + * CAS artifactParameterName. + * @param serviceProperties The ServiceProperties to use to construct the serviceUrl. + */ + public ServiceAuthenticationDetailsSource(ServiceProperties serviceProperties) { + this(serviceProperties, ServiceProperties.DEFAULT_CAS_ARTIFACT_PARAMETER); + } + + /** + * Creates an implementation that uses the specified artifactParameterName + * @param serviceProperties The ServiceProperties to use to construct the serviceUrl. + * @param artifactParameterName the artifactParameterName that is removed from the + * current URL. The result becomes the service url. Cannot be null and cannot be an + * empty String. + */ + public ServiceAuthenticationDetailsSource(ServiceProperties serviceProperties, String artifactParameterName) { + Assert.notNull(serviceProperties, "serviceProperties cannot be null"); + this.serviceProperties = serviceProperties; + this.artifactPattern = DefaultServiceAuthenticationDetails.createArtifactPattern(artifactParameterName); + } + + /** + * @param context the {@code HttpServletRequest} object. + * @return the {@code ServiceAuthenticationDetails} containing information about the + * current request + */ + @Override + public ServiceAuthenticationDetails buildDetails(HttpServletRequest context) { + try { + return new DefaultServiceAuthenticationDetails(this.serviceProperties.getService(), context, + this.artifactPattern); + } + catch (MalformedURLException ex) { + throw new RuntimeException(ex); + } + } + +} diff --git a/cas/src/main/java/org/springframework/security/cas/web/authentication/package-info.java b/cas/src/main/java/org/springframework/security/cas/web/authentication/package-info.java new file mode 100644 index 0000000000..ecd447dbac --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/authentication/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2002-2016 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. + */ + +/** + * Authentication processing mechanisms which respond to the submission of authentication + * credentials using CAS. + */ +package org.springframework.security.cas.web.authentication; diff --git a/cas/src/main/java/org/springframework/security/cas/web/package-info.java b/cas/src/main/java/org/springframework/security/cas/web/package-info.java new file mode 100644 index 0000000000..903fdb8d4c --- /dev/null +++ b/cas/src/main/java/org/springframework/security/cas/web/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2016 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. + */ + +/** + * Authenticates standard web browser users via CAS. + */ +package org.springframework.security.cas.web; diff --git a/cas/src/test/java/org/springframework/security/cas/authentication/AbstractStatelessTicketCacheTests.java b/cas/src/test/java/org/springframework/security/cas/authentication/AbstractStatelessTicketCacheTests.java new file mode 100644 index 0000000000..7f1233b7d5 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/authentication/AbstractStatelessTicketCacheTests.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2016 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.authentication; + +import java.util.ArrayList; +import java.util.List; + +import org.jasig.cas.client.validation.Assertion; +import org.jasig.cas.client.validation.AssertionImpl; + +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.User; + +/** + * @author Scott Battaglia + * @since 2.0 + * + */ +public abstract class AbstractStatelessTicketCacheTests { + + protected CasAuthenticationToken getToken() { + List proxyList = new ArrayList<>(); + proxyList.add("https://localhost/newPortal/login/cas"); + User user = new User("rod", "password", true, true, true, true, + AuthorityUtils.createAuthorityList("ROLE_ONE", "ROLE_TWO")); + final Assertion assertion = new AssertionImpl("rod"); + return new CasAuthenticationToken("key", user, "ST-0-ER94xMJmn6pha35CQRoZ", + AuthorityUtils.createAuthorityList("ROLE_ONE", "ROLE_TWO"), user, assertion); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/authentication/CasAuthenticationProviderTests.java b/cas/src/test/java/org/springframework/security/cas/authentication/CasAuthenticationProviderTests.java new file mode 100644 index 0000000000..242d32a730 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/authentication/CasAuthenticationProviderTests.java @@ -0,0 +1,382 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import java.util.HashMap; +import java.util.Map; + +import org.jasig.cas.client.validation.Assertion; +import org.jasig.cas.client.validation.AssertionImpl; +import org.jasig.cas.client.validation.TicketValidator; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.cas.web.CasAuthenticationFilter; +import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests {@link CasAuthenticationProvider}. + * + * @author Ben Alex + * @author Scott Battaglia + */ +@SuppressWarnings("unchecked") +public class CasAuthenticationProviderTests { + + private UserDetails makeUserDetails() { + return new User("user", "password", true, true, true, true, + AuthorityUtils.createAuthorityList("ROLE_ONE", "ROLE_TWO")); + } + + private UserDetails makeUserDetailsFromAuthoritiesPopulator() { + return new User("user", "password", true, true, true, true, + AuthorityUtils.createAuthorityList("ROLE_A", "ROLE_B")); + } + + private ServiceProperties makeServiceProperties() { + final ServiceProperties serviceProperties = new ServiceProperties(); + serviceProperties.setSendRenew(false); + serviceProperties.setService("http://test.com"); + return serviceProperties; + } + + @Test + public void statefulAuthenticationIsSuccessful() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + StatelessTicketCache cache = new MockStatelessTicketCache(); + cap.setStatelessTicketCache(cache); + cap.setServiceProperties(makeServiceProperties()); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.afterPropertiesSet(); + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( + CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER, "ST-123"); + token.setDetails("details"); + Authentication result = cap.authenticate(token); + // Confirm ST-123 was NOT added to the cache + assertThat(cache.getByTicketId("ST-456") == null).isTrue(); + if (!(result instanceof CasAuthenticationToken)) { + fail("Should have returned a CasAuthenticationToken"); + } + CasAuthenticationToken casResult = (CasAuthenticationToken) result; + assertThat(casResult.getPrincipal()).isEqualTo(makeUserDetailsFromAuthoritiesPopulator()); + assertThat(casResult.getCredentials()).isEqualTo("ST-123"); + assertThat(casResult.getAuthorities()).contains(new SimpleGrantedAuthority("ROLE_A")); + assertThat(casResult.getAuthorities()).contains(new SimpleGrantedAuthority("ROLE_B")); + assertThat(casResult.getKeyHash()).isEqualTo(cap.getKey().hashCode()); + assertThat(casResult.getDetails()).isEqualTo("details"); + // Now confirm the CasAuthenticationToken is automatically re-accepted. + // To ensure TicketValidator not called again, set it to deliver an exception... + cap.setTicketValidator(new MockTicketValidator(false)); + Authentication laterResult = cap.authenticate(result); + assertThat(laterResult).isEqualTo(result); + } + + @Test + public void statelessAuthenticationIsSuccessful() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + StatelessTicketCache cache = new MockStatelessTicketCache(); + cap.setStatelessTicketCache(cache); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + cap.afterPropertiesSet(); + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( + CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER, "ST-456"); + token.setDetails("details"); + Authentication result = cap.authenticate(token); + // Confirm ST-456 was added to the cache + assertThat(cache.getByTicketId("ST-456") != null).isTrue(); + if (!(result instanceof CasAuthenticationToken)) { + fail("Should have returned a CasAuthenticationToken"); + } + assertThat(result.getPrincipal()).isEqualTo(makeUserDetailsFromAuthoritiesPopulator()); + assertThat(result.getCredentials()).isEqualTo("ST-456"); + assertThat(result.getDetails()).isEqualTo("details"); + // Now try to authenticate again. To ensure TicketValidator not + // called again, set it to deliver an exception... + cap.setTicketValidator(new MockTicketValidator(false)); + // Previously created UsernamePasswordAuthenticationToken is OK + Authentication newResult = cap.authenticate(token); + assertThat(newResult.getPrincipal()).isEqualTo(makeUserDetailsFromAuthoritiesPopulator()); + assertThat(newResult.getCredentials()).isEqualTo("ST-456"); + } + + @Test + public void authenticateAllNullService() throws Exception { + String serviceUrl = "https://service/context"; + ServiceAuthenticationDetails details = mock(ServiceAuthenticationDetails.class); + given(details.getServiceUrl()).willReturn(serviceUrl); + TicketValidator validator = mock(TicketValidator.class); + given(validator.validate(any(String.class), any(String.class))).willReturn(new AssertionImpl("rod")); + ServiceProperties serviceProperties = makeServiceProperties(); + serviceProperties.setAuthenticateAllArtifacts(true); + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setTicketValidator(validator); + cap.setServiceProperties(serviceProperties); + cap.afterPropertiesSet(); + String ticket = "ST-456"; + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( + CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER, ticket); + Authentication result = cap.authenticate(token); + } + + @Test + public void authenticateAllAuthenticationIsSuccessful() throws Exception { + String serviceUrl = "https://service/context"; + ServiceAuthenticationDetails details = mock(ServiceAuthenticationDetails.class); + given(details.getServiceUrl()).willReturn(serviceUrl); + TicketValidator validator = mock(TicketValidator.class); + given(validator.validate(any(String.class), any(String.class))).willReturn(new AssertionImpl("rod")); + ServiceProperties serviceProperties = makeServiceProperties(); + serviceProperties.setAuthenticateAllArtifacts(true); + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setTicketValidator(validator); + cap.setServiceProperties(serviceProperties); + cap.afterPropertiesSet(); + String ticket = "ST-456"; + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( + CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER, ticket); + Authentication result = cap.authenticate(token); + verify(validator).validate(ticket, serviceProperties.getService()); + serviceProperties.setAuthenticateAllArtifacts(true); + result = cap.authenticate(token); + verify(validator, times(2)).validate(ticket, serviceProperties.getService()); + token.setDetails(details); + result = cap.authenticate(token); + verify(validator).validate(ticket, serviceUrl); + serviceProperties.setAuthenticateAllArtifacts(false); + serviceProperties.setService(null); + cap.setServiceProperties(serviceProperties); + cap.afterPropertiesSet(); + result = cap.authenticate(token); + verify(validator, times(2)).validate(ticket, serviceUrl); + token.setDetails(new WebAuthenticationDetails(new MockHttpServletRequest())); + assertThatIllegalStateException().isThrownBy(() -> cap.authenticate(token)); + cap.setServiceProperties(null); + cap.afterPropertiesSet(); + assertThatIllegalStateException().isThrownBy(() -> cap.authenticate(token)); + } + + @Test + public void missingTicketIdIsDetected() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + StatelessTicketCache cache = new MockStatelessTicketCache(); + cap.setStatelessTicketCache(cache); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + cap.afterPropertiesSet(); + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken( + CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER, ""); + assertThatExceptionOfType(BadCredentialsException.class).isThrownBy(() -> cap.authenticate(token)); + } + + @Test + public void invalidKeyIsDetected() throws Exception { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + StatelessTicketCache cache = new MockStatelessTicketCache(); + cap.setStatelessTicketCache(cache); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + cap.afterPropertiesSet(); + CasAuthenticationToken token = new CasAuthenticationToken("WRONG_KEY", makeUserDetails(), "credentials", + AuthorityUtils.createAuthorityList("XX"), makeUserDetails(), assertion); + assertThatExceptionOfType(BadCredentialsException.class).isThrownBy(() -> cap.authenticate(token)); + } + + @Test + public void detectsMissingAuthoritiesPopulator() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setKey("qwerty"); + cap.setStatelessTicketCache(new MockStatelessTicketCache()); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + assertThatIllegalArgumentException().isThrownBy(() -> cap.afterPropertiesSet()); + } + + @Test + public void detectsMissingKey() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setStatelessTicketCache(new MockStatelessTicketCache()); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + assertThatIllegalArgumentException().isThrownBy(() -> cap.afterPropertiesSet()); + } + + @Test + public void detectsMissingStatelessTicketCache() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + // set this explicitly to null to test failure + cap.setStatelessTicketCache(null); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + assertThatIllegalArgumentException().isThrownBy(() -> cap.afterPropertiesSet()); + } + + @Test + public void detectsMissingTicketValidator() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setStatelessTicketCache(new MockStatelessTicketCache()); + cap.setServiceProperties(makeServiceProperties()); + assertThatIllegalArgumentException().isThrownBy(() -> cap.afterPropertiesSet()); + } + + @Test + public void gettersAndSettersMatch() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setStatelessTicketCache(new MockStatelessTicketCache()); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + cap.afterPropertiesSet(); + // TODO disabled because why do we need to expose this? + // assertThat(cap.getUserDetailsService() != null).isTrue(); + assertThat(cap.getKey()).isEqualTo("qwerty"); + assertThat(cap.getStatelessTicketCache() != null).isTrue(); + assertThat(cap.getTicketValidator() != null).isTrue(); + } + + @Test + public void ignoresClassesItDoesNotSupport() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setStatelessTicketCache(new MockStatelessTicketCache()); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + cap.afterPropertiesSet(); + TestingAuthenticationToken token = new TestingAuthenticationToken("user", "password", "ROLE_A"); + assertThat(cap.supports(TestingAuthenticationToken.class)).isFalse(); + // Try it anyway + assertThat(cap.authenticate(token)).isNull(); + } + + @Test + public void ignoresUsernamePasswordAuthenticationTokensWithoutCasIdentifiersAsPrincipal() throws Exception { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + cap.setAuthenticationUserDetailsService(new MockAuthoritiesPopulator()); + cap.setKey("qwerty"); + cap.setStatelessTicketCache(new MockStatelessTicketCache()); + cap.setTicketValidator(new MockTicketValidator(true)); + cap.setServiceProperties(makeServiceProperties()); + cap.afterPropertiesSet(); + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("some_normal_user", + "password", AuthorityUtils.createAuthorityList("ROLE_A")); + assertThat(cap.authenticate(token)).isNull(); + } + + @Test + public void supportsRequiredTokens() { + CasAuthenticationProvider cap = new CasAuthenticationProvider(); + assertThat(cap.supports(UsernamePasswordAuthenticationToken.class)).isTrue(); + assertThat(cap.supports(CasAuthenticationToken.class)).isTrue(); + } + + private class MockAuthoritiesPopulator implements AuthenticationUserDetailsService { + + @Override + public UserDetails loadUserDetails(final Authentication token) throws UsernameNotFoundException { + return makeUserDetailsFromAuthoritiesPopulator(); + } + + } + + private class MockStatelessTicketCache implements StatelessTicketCache { + + private Map cache = new HashMap<>(); + + @Override + public CasAuthenticationToken getByTicketId(String serviceTicket) { + return this.cache.get(serviceTicket); + } + + @Override + public void putTicketInCache(CasAuthenticationToken token) { + this.cache.put(token.getCredentials().toString(), token); + } + + @Override + public void removeTicketFromCache(CasAuthenticationToken token) { + throw new UnsupportedOperationException("mock method not implemented"); + } + + @Override + public void removeTicketFromCache(String serviceTicket) { + throw new UnsupportedOperationException("mock method not implemented"); + } + + } + + private class MockTicketValidator implements TicketValidator { + + private boolean returnTicket; + + MockTicketValidator(boolean returnTicket) { + this.returnTicket = returnTicket; + } + + @Override + public Assertion validate(final String ticket, final String service) { + if (this.returnTicket) { + return new AssertionImpl("rod"); + } + throw new BadCredentialsException("As requested from mock"); + } + + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/authentication/CasAuthenticationTokenTests.java b/cas/src/test/java/org/springframework/security/cas/authentication/CasAuthenticationTokenTests.java new file mode 100644 index 0000000000..8ac076830b --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/authentication/CasAuthenticationTokenTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import java.util.Collections; +import java.util.List; + +import org.jasig.cas.client.validation.Assertion; +import org.jasig.cas.client.validation.AssertionImpl; +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests {@link CasAuthenticationToken}. + * + * @author Ben Alex + */ +public class CasAuthenticationTokenTests { + + private final List ROLES = AuthorityUtils.createAuthorityList("ROLE_ONE", "ROLE_TWO"); + + private UserDetails makeUserDetails() { + return makeUserDetails("user"); + } + + private UserDetails makeUserDetails(final String name) { + return new User(name, "password", true, true, true, true, this.ROLES); + } + + @Test + public void testConstructorRejectsNulls() { + Assertion assertion = new AssertionImpl("test"); + assertThatIllegalArgumentException().isThrownBy(() -> new CasAuthenticationToken(null, makeUserDetails(), + "Password", this.ROLES, makeUserDetails(), assertion)); + assertThatIllegalArgumentException().isThrownBy( + () -> new CasAuthenticationToken("key", null, "Password", this.ROLES, makeUserDetails(), assertion)); + assertThatIllegalArgumentException().isThrownBy(() -> new CasAuthenticationToken("key", makeUserDetails(), null, + this.ROLES, makeUserDetails(), assertion)); + assertThatIllegalArgumentException().isThrownBy(() -> new CasAuthenticationToken("key", makeUserDetails(), + "Password", this.ROLES, makeUserDetails(), null)); + assertThatIllegalArgumentException().isThrownBy( + () -> new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, null, assertion)); + assertThatIllegalArgumentException().isThrownBy(() -> new CasAuthenticationToken("key", makeUserDetails(), + "Password", AuthorityUtils.createAuthorityList("ROLE_1", null), makeUserDetails(), assertion)); + } + + @Test + public void constructorWhenEmptyKeyThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy( + () -> new CasAuthenticationToken("", "user", "password", Collections.emptyList(), + new User("user", "password", Collections.emptyList()), null)); + } + + @Test + public void testEqualsWhenEqual() { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token1 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + CasAuthenticationToken token2 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + assertThat(token2).isEqualTo(token1); + } + + @Test + public void testGetters() { + // Build the proxy list returned in the ticket from CAS + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + assertThat(token.getKeyHash()).isEqualTo("key".hashCode()); + assertThat(token.getPrincipal()).isEqualTo(makeUserDetails()); + assertThat(token.getCredentials()).isEqualTo("Password"); + assertThat(token.getAuthorities()).contains(new SimpleGrantedAuthority("ROLE_ONE")); + assertThat(token.getAuthorities()).contains(new SimpleGrantedAuthority("ROLE_TWO")); + assertThat(token.getAssertion()).isEqualTo(assertion); + assertThat(token.getUserDetails().getUsername()).isEqualTo(makeUserDetails().getUsername()); + } + + @Test + public void testNoArgConstructorDoesntExist() { + assertThatExceptionOfType(NoSuchMethodException.class) + .isThrownBy(() -> CasAuthenticationToken.class.getDeclaredConstructor((Class[]) null)); + } + + @Test + public void testNotEqualsDueToAbstractParentEqualsCheck() { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token1 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + CasAuthenticationToken token2 = new CasAuthenticationToken("key", makeUserDetails("OTHER_NAME"), "Password", + this.ROLES, makeUserDetails(), assertion); + assertThat(!token1.equals(token2)).isTrue(); + } + + @Test + public void testNotEqualsDueToDifferentAuthenticationClass() { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token1 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + UsernamePasswordAuthenticationToken token2 = new UsernamePasswordAuthenticationToken("Test", "Password", + this.ROLES); + assertThat(!token1.equals(token2)).isTrue(); + } + + @Test + public void testNotEqualsDueToKey() { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token1 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + CasAuthenticationToken token2 = new CasAuthenticationToken("DIFFERENT_KEY", makeUserDetails(), "Password", + this.ROLES, makeUserDetails(), assertion); + assertThat(!token1.equals(token2)).isTrue(); + } + + @Test + public void testNotEqualsDueToAssertion() { + final Assertion assertion = new AssertionImpl("test"); + final Assertion assertion2 = new AssertionImpl("test"); + CasAuthenticationToken token1 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + CasAuthenticationToken token2 = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion2); + assertThat(!token1.equals(token2)).isTrue(); + } + + @Test + public void testSetAuthenticated() { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + assertThat(token.isAuthenticated()).isTrue(); + token.setAuthenticated(false); + assertThat(!token.isAuthenticated()).isTrue(); + } + + @Test + public void testToString() { + final Assertion assertion = new AssertionImpl("test"); + CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES, + makeUserDetails(), assertion); + String result = token.toString(); + assertThat(result.lastIndexOf("Credentials (Service/Proxy Ticket):") != -1).isTrue(); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/authentication/NullStatelessTicketCacheTests.java b/cas/src/test/java/org/springframework/security/cas/authentication/NullStatelessTicketCacheTests.java new file mode 100644 index 0000000000..f5a87e5c4e --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/authentication/NullStatelessTicketCacheTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test cases for the @link {@link NullStatelessTicketCache} + * + * @author Scott Battaglia + * + */ +public class NullStatelessTicketCacheTests extends AbstractStatelessTicketCacheTests { + + private StatelessTicketCache cache = new NullStatelessTicketCache(); + + @Test + public void testGetter() { + assertThat(this.cache.getByTicketId(null)).isNull(); + assertThat(this.cache.getByTicketId("test")).isNull(); + } + + @Test + public void testInsertAndGet() { + final CasAuthenticationToken token = getToken(); + this.cache.putTicketInCache(token); + assertThat(this.cache.getByTicketId((String) token.getCredentials())).isNull(); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/authentication/SpringCacheBasedTicketCacheTests.java b/cas/src/test/java/org/springframework/security/cas/authentication/SpringCacheBasedTicketCacheTests.java new file mode 100644 index 0000000000..e27344a9ce --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/authentication/SpringCacheBasedTicketCacheTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.authentication; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests + * {@link org.springframework.security.cas.authentication.SpringCacheBasedTicketCache}. + * + * @author Marten Deinum + * @since 3.2 + */ +public class SpringCacheBasedTicketCacheTests extends AbstractStatelessTicketCacheTests { + + private static CacheManager cacheManager; + + @BeforeAll + public static void initCacheManaer() { + cacheManager = new ConcurrentMapCacheManager(); + cacheManager.getCache("castickets"); + } + + @Test + public void testCacheOperation() throws Exception { + SpringCacheBasedTicketCache cache = new SpringCacheBasedTicketCache(cacheManager.getCache("castickets")); + final CasAuthenticationToken token = getToken(); + // Check it gets stored in the cache + cache.putTicketInCache(token); + assertThat(cache.getByTicketId("ST-0-ER94xMJmn6pha35CQRoZ")).isEqualTo(token); + // Check it gets removed from the cache + cache.removeTicketFromCache(getToken()); + assertThat(cache.getByTicketId("ST-0-ER94xMJmn6pha35CQRoZ")).isNull(); + // Check it doesn't return values for null or unknown service tickets + assertThat(cache.getByTicketId(null)).isNull(); + assertThat(cache.getByTicketId("UNKNOWN_SERVICE_TICKET")).isNull(); + } + + @Test + public void testStartupDetectsMissingCache() throws Exception { + assertThatIllegalArgumentException().isThrownBy(() -> new SpringCacheBasedTicketCache(null)); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/jackson2/CasAuthenticationTokenMixinTests.java b/cas/src/test/java/org/springframework/security/cas/jackson2/CasAuthenticationTokenMixinTests.java new file mode 100644 index 0000000000..ce333d4d83 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/jackson2/CasAuthenticationTokenMixinTests.java @@ -0,0 +1,153 @@ +/* + * Copyright 2015-2016 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.jackson2; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jasig.cas.client.authentication.AttributePrincipalImpl; +import org.jasig.cas.client.validation.Assertion; +import org.jasig.cas.client.validation.AssertionImpl; +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import org.springframework.security.cas.authentication.CasAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.jackson2.SecurityJackson2Modules; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jitendra Singh + * @since 4.2 + */ +public class CasAuthenticationTokenMixinTests { + + private static final String KEY = "casKey"; + + private static final String PASSWORD = "\"1234\""; + + private static final Date START_DATE = new Date(); + + private static final Date END_DATE = new Date(); + + public static final String AUTHORITY_JSON = "{\"@class\": \"org.springframework.security.core.authority.SimpleGrantedAuthority\", \"authority\": \"ROLE_USER\"}"; + + public static final String AUTHORITIES_SET_JSON = "[\"java.util.Collections$UnmodifiableSet\", [" + AUTHORITY_JSON + + "]]"; + + public static final String AUTHORITIES_ARRAYLIST_JSON = "[\"java.util.Collections$UnmodifiableRandomAccessList\", [" + + AUTHORITY_JSON + "]]"; + + // @formatter:off + public static final String USER_JSON = "{" + + "\"@class\": \"org.springframework.security.core.userdetails.User\", " + + "\"username\": \"admin\"," + + " \"password\": " + PASSWORD + ", " + + "\"accountNonExpired\": true, " + + "\"accountNonLocked\": true, " + + "\"credentialsNonExpired\": true, " + + "\"enabled\": true, " + + "\"authorities\": " + AUTHORITIES_SET_JSON + + "}"; + // @formatter:on + private static final String CAS_TOKEN_JSON = "{" + + "\"@class\": \"org.springframework.security.cas.authentication.CasAuthenticationToken\", " + + "\"keyHash\": " + KEY.hashCode() + "," + "\"principal\": " + USER_JSON + ", " + "\"credentials\": " + + PASSWORD + ", " + "\"authorities\": " + AUTHORITIES_ARRAYLIST_JSON + "," + "\"userDetails\": " + USER_JSON + + "," + "\"authenticated\": true, " + "\"details\": null," + "\"assertion\": {" + + "\"@class\": \"org.jasig.cas.client.validation.AssertionImpl\", " + "\"principal\": {" + + "\"@class\": \"org.jasig.cas.client.authentication.AttributePrincipalImpl\", " + + "\"name\": \"assertName\", " + "\"attributes\": {\"@class\": \"java.util.Collections$EmptyMap\"}, " + + "\"proxyGrantingTicket\": null, " + "\"proxyRetriever\": null" + "}, " + + "\"validFromDate\": [\"java.util.Date\", " + START_DATE.getTime() + "], " + + "\"validUntilDate\": [\"java.util.Date\", " + END_DATE.getTime() + "]," + + "\"authenticationDate\": [\"java.util.Date\", " + START_DATE.getTime() + "], " + + "\"attributes\": {\"@class\": \"java.util.Collections$EmptyMap\"}" + "}" + "}"; + + private static final String CAS_TOKEN_CLEARED_JSON = CAS_TOKEN_JSON.replaceFirst(PASSWORD, "null"); + + protected ObjectMapper mapper; + + @BeforeEach + public void setup() { + this.mapper = new ObjectMapper(); + ClassLoader loader = getClass().getClassLoader(); + this.mapper.registerModules(SecurityJackson2Modules.getModules(loader)); + } + + @Test + public void serializeCasAuthenticationTest() throws JsonProcessingException, JSONException { + CasAuthenticationToken token = createCasAuthenticationToken(); + String actualJson = this.mapper.writeValueAsString(token); + JSONAssert.assertEquals(CAS_TOKEN_JSON, actualJson, true); + } + + @Test + public void serializeCasAuthenticationTestAfterEraseCredentialInvoked() + throws JsonProcessingException, JSONException { + CasAuthenticationToken token = createCasAuthenticationToken(); + token.eraseCredentials(); + String actualJson = this.mapper.writeValueAsString(token); + JSONAssert.assertEquals(CAS_TOKEN_CLEARED_JSON, actualJson, true); + } + + @Test + public void deserializeCasAuthenticationTestAfterEraseCredentialInvoked() throws Exception { + CasAuthenticationToken token = this.mapper.readValue(CAS_TOKEN_CLEARED_JSON, CasAuthenticationToken.class); + assertThat(((UserDetails) token.getPrincipal()).getPassword()).isNull(); + } + + @Test + public void deserializeCasAuthenticationTest() throws IOException { + CasAuthenticationToken token = this.mapper.readValue(CAS_TOKEN_JSON, CasAuthenticationToken.class); + assertThat(token).isNotNull(); + assertThat(token.getPrincipal()).isNotNull().isInstanceOf(User.class); + assertThat(((User) token.getPrincipal()).getUsername()).isEqualTo("admin"); + assertThat(((User) token.getPrincipal()).getPassword()).isEqualTo("1234"); + assertThat(token.getUserDetails()).isNotNull().isInstanceOf(User.class); + assertThat(token.getAssertion()).isNotNull().isInstanceOf(AssertionImpl.class); + assertThat(token.getKeyHash()).isEqualTo(KEY.hashCode()); + assertThat(token.getUserDetails().getAuthorities()).extracting(GrantedAuthority::getAuthority) + .containsOnly("ROLE_USER"); + assertThat(token.getAssertion().getAuthenticationDate()).isEqualTo(START_DATE); + assertThat(token.getAssertion().getValidFromDate()).isEqualTo(START_DATE); + assertThat(token.getAssertion().getValidUntilDate()).isEqualTo(END_DATE); + assertThat(token.getAssertion().getPrincipal().getName()).isEqualTo("assertName"); + assertThat(token.getAssertion().getAttributes()).hasSize(0); + } + + private CasAuthenticationToken createCasAuthenticationToken() { + User principal = new User("admin", "1234", Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + Collection authorities = Collections + .singletonList(new SimpleGrantedAuthority("ROLE_USER")); + Assertion assertion = new AssertionImpl(new AttributePrincipalImpl("assertName"), START_DATE, END_DATE, + START_DATE, Collections.emptyMap()); + return new CasAuthenticationToken(KEY, principal, principal.getPassword(), authorities, + new User("admin", "1234", authorities), assertion); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/userdetails/GrantedAuthorityFromAssertionAttributesUserDetailsServiceTests.java b/cas/src/test/java/org/springframework/security/cas/userdetails/GrantedAuthorityFromAssertionAttributesUserDetailsServiceTests.java new file mode 100644 index 0000000000..3db719dcc2 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/userdetails/GrantedAuthorityFromAssertionAttributesUserDetailsServiceTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2017 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.userdetails; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.jasig.cas.client.authentication.AttributePrincipal; +import org.jasig.cas.client.validation.Assertion; +import org.junit.jupiter.api.Test; + +import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.UserDetails; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * @author Luke Taylor + */ +public class GrantedAuthorityFromAssertionAttributesUserDetailsServiceTests { + + @Test + public void correctlyExtractsNamedAttributesFromAssertionAndConvertsThemToAuthorities() { + GrantedAuthorityFromAssertionAttributesUserDetailsService uds = new GrantedAuthorityFromAssertionAttributesUserDetailsService( + new String[] { "a", "b", "c", "d" }); + uds.setConvertToUpperCase(false); + Assertion assertion = mock(Assertion.class); + AttributePrincipal principal = mock(AttributePrincipal.class); + Map attributes = new HashMap<>(); + attributes.put("a", Arrays.asList("role_a1", "role_a2")); + attributes.put("b", "role_b"); + attributes.put("c", "role_c"); + attributes.put("d", null); + attributes.put("someother", "unused"); + given(assertion.getPrincipal()).willReturn(principal); + given(principal.getAttributes()).willReturn(attributes); + given(principal.getName()).willReturn("somebody"); + CasAssertionAuthenticationToken token = new CasAssertionAuthenticationToken(assertion, "ticket"); + UserDetails user = uds.loadUserDetails(token); + Set roles = AuthorityUtils.authorityListToSet(user.getAuthorities()); + assertThat(roles).containsExactlyInAnyOrder("role_a1", "role_a2", "role_b", "role_c"); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationEntryPointTests.java b/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationEntryPointTests.java new file mode 100644 index 0000000000..5fb0e7c7f8 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationEntryPointTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.net.URLEncoder; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.cas.ServiceProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests {@link CasAuthenticationEntryPoint}. + * + * @author Ben Alex + */ +public class CasAuthenticationEntryPointTests { + + @Test + public void testDetectsMissingLoginFormUrl() throws Exception { + CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint(); + ep.setServiceProperties(new ServiceProperties()); + assertThatIllegalArgumentException().isThrownBy(ep::afterPropertiesSet) + .withMessage("loginUrl must be specified"); + } + + @Test + public void testDetectsMissingServiceProperties() throws Exception { + CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint(); + ep.setLoginUrl("https://cas/login"); + assertThatIllegalArgumentException().isThrownBy(ep::afterPropertiesSet) + .withMessage("serviceProperties must be specified"); + } + + @Test + public void testGettersSetters() { + CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint(); + ep.setLoginUrl("https://cas/login"); + assertThat(ep.getLoginUrl()).isEqualTo("https://cas/login"); + ep.setServiceProperties(new ServiceProperties()); + assertThat(ep.getServiceProperties() != null).isTrue(); + } + + @Test + public void testNormalOperationWithRenewFalse() throws Exception { + ServiceProperties sp = new ServiceProperties(); + sp.setSendRenew(false); + sp.setService("https://mycompany.com/bigWebApp/login/cas"); + CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint(); + ep.setLoginUrl("https://cas/login"); + ep.setServiceProperties(sp); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some_path"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ep.afterPropertiesSet(); + ep.commence(request, response, null); + assertThat( + "https://cas/login?service=" + URLEncoder.encode("https://mycompany.com/bigWebApp/login/cas", "UTF-8")) + .isEqualTo(response.getRedirectedUrl()); + } + + @Test + public void testNormalOperationWithRenewTrue() throws Exception { + ServiceProperties sp = new ServiceProperties(); + sp.setSendRenew(true); + sp.setService("https://mycompany.com/bigWebApp/login/cas"); + CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint(); + ep.setLoginUrl("https://cas/login"); + ep.setServiceProperties(sp); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some_path"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ep.afterPropertiesSet(); + ep.commence(request, response, null); + assertThat("https://cas/login?service=" + + URLEncoder.encode("https://mycompany.com/bigWebApp/login/cas", "UTF-8") + "&renew=true") + .isEqualTo(response.getRedirectedUrl()); + } + +} 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 new file mode 100644 index 0000000000..f19222baf2 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/web/CasAuthenticationFilterTests.java @@ -0,0 +1,199 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.FilterChain; + +import org.jasig.cas.client.proxy.ProxyGrantingTicketStorage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests {@link CasAuthenticationFilter}. + * + * @author Ben Alex + * @author Rob Winch + */ +public class CasAuthenticationFilterTests { + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testGettersSetters() { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setProxyGrantingTicketStorage(mock(ProxyGrantingTicketStorage.class)); + filter.setProxyReceptorUrl("/someurl"); + filter.setServiceProperties(new ServiceProperties()); + } + + @Test + public void testNormalOperation() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/login/cas"); + request.addParameter("ticket", "ST-0-ER94xMJmn6pha35CQRoZ"); + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setAuthenticationManager((a) -> a); + assertThat(filter.requiresAuthentication(request, new MockHttpServletResponse())).isTrue(); + Authentication result = filter.attemptAuthentication(request, new MockHttpServletResponse()); + assertThat(result != null).isTrue(); + } + + @Test + public void testNullServiceTicketHandledGracefully() throws Exception { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setAuthenticationManager((a) -> { + throw new BadCredentialsException("Rejected"); + }); + assertThatExceptionOfType(AuthenticationException.class).isThrownBy( + () -> filter.attemptAuthentication(new MockHttpServletRequest(), new MockHttpServletResponse())); + } + + @Test + public void testRequiresAuthenticationFilterProcessUrl() { + String url = "/login/cas"; + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setFilterProcessesUrl(url); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setServletPath(url); + assertThat(filter.requiresAuthentication(request, response)).isTrue(); + } + + @Test + public void testRequiresAuthenticationProxyRequest() { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setServletPath("/pgtCallback"); + assertThat(filter.requiresAuthentication(request, response)).isFalse(); + filter.setProxyReceptorUrl(request.getServletPath()); + assertThat(filter.requiresAuthentication(request, response)).isFalse(); + filter.setProxyGrantingTicketStorage(mock(ProxyGrantingTicketStorage.class)); + assertThat(filter.requiresAuthentication(request, response)).isTrue(); + request.setServletPath("/other"); + assertThat(filter.requiresAuthentication(request, response)).isFalse(); + } + + @Test + public void testRequiresAuthenticationAuthAll() { + ServiceProperties properties = new ServiceProperties(); + properties.setAuthenticateAllArtifacts(true); + String url = "/login/cas"; + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setFilterProcessesUrl(url); + filter.setServiceProperties(properties); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setServletPath(url); + assertThat(filter.requiresAuthentication(request, response)).isTrue(); + request.setServletPath("/other"); + assertThat(filter.requiresAuthentication(request, response)).isFalse(); + request.setParameter(properties.getArtifactParameter(), "value"); + assertThat(filter.requiresAuthentication(request, response)).isTrue(); + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("key", "principal", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + assertThat(filter.requiresAuthentication(request, response)).isTrue(); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("un", "principal")); + assertThat(filter.requiresAuthentication(request, response)).isTrue(); + SecurityContextHolder.getContext() + .setAuthentication(new TestingAuthenticationToken("un", "principal", "ROLE_ANONYMOUS")); + assertThat(filter.requiresAuthentication(request, response)).isFalse(); + } + + @Test + public void testAuthenticateProxyUrl() throws Exception { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.setServletPath("/pgtCallback"); + filter.setProxyGrantingTicketStorage(mock(ProxyGrantingTicketStorage.class)); + filter.setProxyReceptorUrl(request.getServletPath()); + assertThat(filter.attemptAuthentication(request, response)).isNull(); + } + + @Test + public void testDoFilterAuthenticateAll() throws Exception { + AuthenticationSuccessHandler successHandler = mock(AuthenticationSuccessHandler.class); + AuthenticationManager manager = mock(AuthenticationManager.class); + Authentication authentication = new TestingAuthenticationToken("un", "pwd", "ROLE_USER"); + given(manager.authenticate(any(Authentication.class))).willReturn(authentication); + ServiceProperties serviceProperties = new ServiceProperties(); + serviceProperties.setAuthenticateAllArtifacts(true); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("ticket", "ST-1-123"); + request.setServletPath("/authenticate"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + filter.setServiceProperties(serviceProperties); + filter.setAuthenticationSuccessHandler(successHandler); + filter.setProxyGrantingTicketStorage(mock(ProxyGrantingTicketStorage.class)); + filter.setAuthenticationManager(manager); + filter.afterPropertiesSet(); + filter.doFilter(request, response, chain); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull() + .withFailMessage("Authentication should not be null"); + verify(chain).doFilter(request, response); + verifyZeroInteractions(successHandler); + // validate for when the filterProcessUrl matches + filter.setFilterProcessesUrl(request.getServletPath()); + SecurityContextHolder.clearContext(); + filter.doFilter(request, response, chain); + verifyNoMoreInteractions(chain); + verify(successHandler).onAuthenticationSuccess(request, response, authentication); + } + + // SEC-1592 + @Test + public void testChainNotInvokedForProxyReceptor() throws Exception { + CasAuthenticationFilter filter = new CasAuthenticationFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + request.setServletPath("/pgtCallback"); + filter.setProxyGrantingTicketStorage(mock(ProxyGrantingTicketStorage.class)); + filter.setProxyReceptorUrl(request.getServletPath()); + filter.doFilter(request, response, chain); + verifyZeroInteractions(chain); + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/web/ServicePropertiesTests.java b/cas/src/test/java/org/springframework/security/cas/web/ServicePropertiesTests.java new file mode 100644 index 0000000000..fc52e1d6a5 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/web/ServicePropertiesTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited + * + * 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.security.cas.SamlServiceProperties; +import org.springframework.security.cas.ServiceProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests {@link ServiceProperties}. + * + * @author Ben Alex + */ +public class ServicePropertiesTests { + + @Test + public void detectsMissingService() throws Exception { + ServiceProperties sp = new ServiceProperties(); + assertThatIllegalArgumentException().isThrownBy(sp::afterPropertiesSet); + } + + @Test + public void nullServiceWhenAuthenticateAllTokens() throws Exception { + ServiceProperties sp = new ServiceProperties(); + sp.setAuthenticateAllArtifacts(true); + assertThatIllegalArgumentException().isThrownBy(sp::afterPropertiesSet); + sp.setAuthenticateAllArtifacts(false); + assertThatIllegalArgumentException().isThrownBy(sp::afterPropertiesSet); + } + + @Test + public void testGettersSetters() throws Exception { + ServiceProperties[] sps = { new ServiceProperties(), new SamlServiceProperties() }; + for (ServiceProperties sp : sps) { + sp.setSendRenew(false); + assertThat(sp.isSendRenew()).isFalse(); + sp.setSendRenew(true); + assertThat(sp.isSendRenew()).isTrue(); + sp.setArtifactParameter("notticket"); + assertThat(sp.getArtifactParameter()).isEqualTo("notticket"); + sp.setServiceParameter("notservice"); + assertThat(sp.getServiceParameter()).isEqualTo("notservice"); + sp.setService("https://mycompany.com/service"); + assertThat(sp.getService()).isEqualTo("https://mycompany.com/service"); + sp.afterPropertiesSet(); + } + } + +} diff --git a/cas/src/test/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetailsTests.java b/cas/src/test/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetailsTests.java new file mode 100644 index 0000000000..893d256775 --- /dev/null +++ b/cas/src/test/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetailsTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2011-2016 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.authentication; + +import java.util.regex.Pattern; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.GenericXmlApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.cas.ServiceProperties; +import org.springframework.security.web.util.UrlUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + */ +public class DefaultServiceAuthenticationDetailsTests { + + private DefaultServiceAuthenticationDetails details; + + private MockHttpServletRequest request; + + private Pattern artifactPattern; + + private String casServiceUrl; + + private ConfigurableApplicationContext context; + + @BeforeEach + public void setUp() { + this.casServiceUrl = "https://localhost:8443/j_spring_security_cas"; + this.request = new MockHttpServletRequest(); + this.request.setScheme("https"); + this.request.setServerName("localhost"); + this.request.setServerPort(8443); + this.request.setRequestURI("/cas-sample/secure/"); + this.artifactPattern = DefaultServiceAuthenticationDetails + .createArtifactPattern(ServiceProperties.DEFAULT_CAS_ARTIFACT_PARAMETER); + } + + @AfterEach + public void cleanup() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void getServiceUrlNullQuery() throws Exception { + this.details = new DefaultServiceAuthenticationDetails(this.casServiceUrl, this.request, this.artifactPattern); + assertThat(this.details.getServiceUrl()).isEqualTo(UrlUtils.buildFullRequestUrl(this.request)); + } + + @Test + public void getServiceUrlTicketOnlyParam() throws Exception { + this.request.setQueryString("ticket=123"); + this.details = new DefaultServiceAuthenticationDetails(this.casServiceUrl, this.request, this.artifactPattern); + String serviceUrl = this.details.getServiceUrl(); + this.request.setQueryString(null); + assertThat(serviceUrl).isEqualTo(UrlUtils.buildFullRequestUrl(this.request)); + } + + @Test + public void getServiceUrlTicketFirstMultiParam() throws Exception { + this.request.setQueryString("ticket=123&other=value"); + this.details = new DefaultServiceAuthenticationDetails(this.casServiceUrl, this.request, this.artifactPattern); + String serviceUrl = this.details.getServiceUrl(); + this.request.setQueryString("other=value"); + assertThat(serviceUrl).isEqualTo(UrlUtils.buildFullRequestUrl(this.request)); + } + + @Test + public void getServiceUrlTicketLastMultiParam() throws Exception { + this.request.setQueryString("other=value&ticket=123"); + this.details = new DefaultServiceAuthenticationDetails(this.casServiceUrl, this.request, this.artifactPattern); + String serviceUrl = this.details.getServiceUrl(); + this.request.setQueryString("other=value"); + assertThat(serviceUrl).isEqualTo(UrlUtils.buildFullRequestUrl(this.request)); + } + + @Test + public void getServiceUrlTicketMiddleMultiParam() throws Exception { + this.request.setQueryString("other=value&ticket=123&last=this"); + this.details = new DefaultServiceAuthenticationDetails(this.casServiceUrl, this.request, this.artifactPattern); + String serviceUrl = this.details.getServiceUrl(); + this.request.setQueryString("other=value&last=this"); + assertThat(serviceUrl).isEqualTo(UrlUtils.buildFullRequestUrl(this.request)); + } + + @Test + public void getServiceUrlDoesNotUseHostHeader() throws Exception { + this.casServiceUrl = "https://example.com/j_spring_security_cas"; + this.request.setServerName("evil.com"); + this.details = new DefaultServiceAuthenticationDetails(this.casServiceUrl, this.request, this.artifactPattern); + assertThat(this.details.getServiceUrl()).isEqualTo("https://example.com/cas-sample/secure/"); + } + + @Test + public void getServiceUrlDoesNotUseHostHeaderExplicit() { + this.casServiceUrl = "https://example.com/j_spring_security_cas"; + this.request.setServerName("evil.com"); + ServiceAuthenticationDetails details = loadServiceAuthenticationDetails( + "defaultserviceauthenticationdetails-explicit.xml"); + assertThat(details.getServiceUrl()).isEqualTo("https://example.com/cas-sample/secure/"); + } + + private ServiceAuthenticationDetails loadServiceAuthenticationDetails(String resourceName) { + this.context = new GenericXmlApplicationContext(getClass(), resourceName); + ServiceAuthenticationDetailsSource source = this.context.getBean(ServiceAuthenticationDetailsSource.class); + return source.buildDetails(this.request); + } + +} diff --git a/cas/src/test/resources/logback-test.xml b/cas/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..2d51ba4180 --- /dev/null +++ b/cas/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/cas/src/test/resources/org/springframework/security/cas/web/authentication/defaultserviceauthenticationdetails-explicit.xml b/cas/src/test/resources/org/springframework/security/cas/web/authentication/defaultserviceauthenticationdetails-explicit.xml new file mode 100644 index 0000000000..c7d5346179 --- /dev/null +++ b/cas/src/test/resources/org/springframework/security/cas/web/authentication/defaultserviceauthenticationdetails-explicit.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cas/src/test/resources/org/springframework/security/cas/web/authentication/defaultserviceauthenticationdetails-passivity.xml b/cas/src/test/resources/org/springframework/security/cas/web/authentication/defaultserviceauthenticationdetails-passivity.xml new file mode 100644 index 0000000000..0fe950ff2b --- /dev/null +++ b/cas/src/test/resources/org/springframework/security/cas/web/authentication/defaultserviceauthenticationdetails-passivity.xml @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index ac076ddd60..82cd9e84b6 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -41,6 +41,7 @@ dependencies { provided 'jakarta.servlet:jakarta.servlet-api' testImplementation project(':spring-security-aspects') + testImplementation project(':spring-security-cas') testImplementation project(':spring-security-test') testImplementation project(path : ':spring-security-core', configuration : 'tests') testImplementation project(path : ':spring-security-ldap', configuration : 'tests') diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/builders/HttpConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/builders/HttpConfigurationTests.java index 23ab994721..b642bf287e 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/builders/HttpConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/builders/HttpConfigurationTests.java @@ -20,6 +20,8 @@ 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 org.junit.jupiter.api.Test; @@ -29,6 +31,8 @@ import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.cas.web.CasAuthenticationFilter; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; @@ -42,6 +46,9 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -68,6 +75,16 @@ public class HttpConfigurationTests { + " Consider using addFilterBefore or addFilterAfter instead."); } + // https://github.com/spring-projects/spring-security-javaconfig/issues/104 + @Test + public void configureWhenAddFilterCasAuthenticationFilterThenFilterAdded() throws Exception { + CasAuthenticationFilterConfig.CAS_AUTHENTICATION_FILTER = spy(new CasAuthenticationFilter()); + this.spring.register(CasAuthenticationFilterConfig.class).autowire(); + this.mockMvc.perform(get("/")); + verify(CasAuthenticationFilterConfig.CAS_AUTHENTICATION_FILTER).doFilter(any(ServletRequest.class), + any(ServletResponse.class), any(FilterChain.class)); + } + @Test public void configureWhenConfigIsRequestMatchersJavadocThenAuthorizationApplied() throws Exception { this.spring.register(RequestMatcherRegistryConfigs.class).autowire(); @@ -107,6 +124,21 @@ public class HttpConfigurationTests { } + @EnableWebSecurity + static class CasAuthenticationFilterConfig extends WebSecurityConfigurerAdapter { + + static CasAuthenticationFilter CAS_AUTHENTICATION_FILTER; + + @Override + protected void configure(HttpSecurity http) { + // @formatter:off + http + .addFilter(CAS_AUTHENTICATION_FILTER); + // @formatter:on + } + + } + @Configuration @EnableWebSecurity @EnableWebMvc diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index bc274ccb8c..cb4559cc31 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -49,6 +49,7 @@ *** xref:servlet/authentication/anonymous.adoc[Anonymous] *** xref:servlet/authentication/preauth.adoc[Pre-Authentication] *** xref:servlet/authentication/jaas.adoc[JAAS] +*** xref:servlet/authentication/cas.adoc[CAS] *** xref:servlet/authentication/x509.adoc[X509] *** xref:servlet/authentication/runas.adoc[Run-As] *** xref:servlet/authentication/logout.adoc[Logout] diff --git a/docs/modules/ROOT/pages/servlet/authentication/cas.adoc b/docs/modules/ROOT/pages/servlet/authentication/cas.adoc new file mode 100644 index 0000000000..5b6dd7617e --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/cas.adoc @@ -0,0 +1,463 @@ +[[servlet-cas]] += CAS Authentication + +[[cas-overview]] +== Overview +JA-SIG produces an enterprise-wide single sign on system known as CAS. +Unlike other initiatives, JA-SIG's Central Authentication Service is open source, widely used, simple to understand, platform independent, and supports proxy capabilities. +Spring Security fully supports CAS, and provides an easy migration path from single-application deployments of Spring Security through to multiple-application deployments secured by an enterprise-wide CAS server. + +You can learn more about CAS at https://www.apereo.org. +You will also need to visit this site to download the CAS Server files. + +[[cas-how-it-works]] +== How CAS Works +Whilst the CAS web site contains documents that detail the architecture of CAS, we present the general overview again here within the context of Spring Security. +Spring Security 3.x supports CAS 3. +At the time of writing, the CAS server was at version 3.4. + +Somewhere in your enterprise you will need to setup a CAS server. +The CAS server is simply a standard WAR file, so there isn't anything difficult about setting up your server. +Inside the WAR file you will customise the login and other single sign on pages displayed to users. + +When deploying a CAS 3.4 server, you will also need to specify an `AuthenticationHandler` in the `deployerConfigContext.xml` included with CAS. +The `AuthenticationHandler` has a simple method that returns a boolean as to whether a given set of Credentials is valid. +Your `AuthenticationHandler` implementation will need to link into some type of backend authentication repository, such as an LDAP server or database. +CAS itself includes numerous ``AuthenticationHandler``s out of the box to assist with this. +When you download and deploy the server war file, it is set up to successfully authenticate users who enter a password matching their username, which is useful for testing. + +Apart from the CAS server itself, the other key players are of course the secure web applications deployed throughout your enterprise. +These web applications are known as "services". +There are three types of services. +Those that authenticate service tickets, those that can obtain proxy tickets, and those that authenticate proxy tickets. +Authenticating a proxy ticket differs because the list of proxies must be validated and often times a proxy ticket can be reused. + + +[[cas-sequence]] +=== Spring Security and CAS Interaction Sequence +The basic interaction between a web browser, CAS server and a Spring Security-secured service is as follows: + +* The web user is browsing the service's public pages. +CAS or Spring Security is not involved. +* The user eventually requests a page that is either secure or one of the beans it uses is secure. +Spring Security's `ExceptionTranslationFilter` will detect the `AccessDeniedException` or `AuthenticationException`. +* Because the user's `Authentication` object (or lack thereof) caused an `AuthenticationException`, the `ExceptionTranslationFilter` will call the configured `AuthenticationEntryPoint`. +If using CAS, this will be the `CasAuthenticationEntryPoint` class. +* The `CasAuthenticationEntryPoint` will redirect the user's browser to the CAS server. +It will also indicate a `service` parameter, which is the callback URL for the Spring Security service (your application). +For example, the URL to which the browser is redirected might be https://my.company.com/cas/login?service=https%3A%2F%2Fserver3.company.com%2Fwebapp%2Flogin/cas. +* After the user's browser redirects to CAS, they will be prompted for their username and password. +If the user presents a session cookie which indicates they've previously logged on, they will not be prompted to login again (there is an exception to this procedure, which we'll cover later). +CAS will use the `PasswordHandler` (or `AuthenticationHandler` if using CAS 3.0) discussed above to decide whether the username and password is valid. +* Upon successful login, CAS will redirect the user's browser back to the original service. +It will also include a `ticket` parameter, which is an opaque string representing the "service ticket". +Continuing our earlier example, the URL the browser is redirected to might be https://server3.company.com/webapp/login/cas?ticket=ST-0-ER94xMJmn6pha35CQRoZ. +* Back in the service web application, the `CasAuthenticationFilter` is always listening for requests to `/login/cas` (this is configurable, but we'll use the defaults in this introduction). +The processing filter will construct a `UsernamePasswordAuthenticationToken` representing the service ticket. +The principal will be equal to `CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER`, whilst the credentials will be the service ticket opaque value. +This authentication request will then be handed to the configured `AuthenticationManager`. +* The `AuthenticationManager` implementation will be the `ProviderManager`, which is in turn configured with the `CasAuthenticationProvider`. +The `CasAuthenticationProvider` only responds to ``UsernamePasswordAuthenticationToken``s containing the CAS-specific principal (such as `CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER`) and ``CasAuthenticationToken``s (discussed later). +* `CasAuthenticationProvider` will validate the service ticket using a `TicketValidator` implementation. +This will typically be a `Cas20ServiceTicketValidator` which is one of the classes included in the CAS client library. +In the event the application needs to validate proxy tickets, the `Cas20ProxyTicketValidator` is used. +The `TicketValidator` makes an HTTPS request to the CAS server in order to validate the service ticket. +It may also include a proxy callback URL, which is included in this example: https://my.company.com/cas/proxyValidate?service=https%3A%2F%2Fserver3.company.com%2Fwebapp%2Flogin/cas&ticket=ST-0-ER94xMJmn6pha35CQRoZ&pgtUrl=https://server3.company.com/webapp/login/cas/proxyreceptor. +* Back on the CAS server, the validation request will be received. +If the presented service ticket matches the service URL the ticket was issued to, CAS will provide an affirmative response in XML indicating the username. +If any proxy was involved in the authentication (discussed below), the list of proxies is also included in the XML response. +* [OPTIONAL] If the request to the CAS validation service included the proxy callback URL (in the `pgtUrl` parameter), CAS will include a `pgtIou` string in the XML response. +This `pgtIou` represents a proxy-granting ticket IOU. +The CAS server will then create its own HTTPS connection back to the `pgtUrl`. +This is to mutually authenticate the CAS server and the claimed service URL. +The HTTPS connection will be used to send a proxy granting ticket to the original web application. +For example, https://server3.company.com/webapp/login/cas/proxyreceptor?pgtIou=PGTIOU-0-R0zlgrl4pdAQwBvJWO3vnNpevwqStbSGcq3vKB2SqSFFRnjPHt&pgtId=PGT-1-si9YkkHLrtACBo64rmsi3v2nf7cpCResXg5MpESZFArbaZiOKH. +* The `Cas20TicketValidator` will parse the XML received from the CAS server. +It will return to the `CasAuthenticationProvider` a `TicketResponse`, which includes the username (mandatory), proxy list (if any were involved), and proxy-granting ticket IOU (if the proxy callback was requested). +* Next `CasAuthenticationProvider` will call a configured `CasProxyDecider`. +The `CasProxyDecider` indicates whether the proxy list in the `TicketResponse` is acceptable to the service. +Several implementations are provided with Spring Security: `RejectProxyTickets`, `AcceptAnyCasProxy` and `NamedCasProxyDecider`. +These names are largely self-explanatory, except `NamedCasProxyDecider` which allows a `List` of trusted proxies to be provided. +* `CasAuthenticationProvider` will next request a `AuthenticationUserDetailsService` to load the `GrantedAuthority` objects that apply to the user contained in the `Assertion`. +* If there were no problems, `CasAuthenticationProvider` constructs a `CasAuthenticationToken` including the details contained in the `TicketResponse` and the ``GrantedAuthority``s. +* Control then returns to `CasAuthenticationFilter`, which places the created `CasAuthenticationToken` in the security context. +* The user's browser is redirected to the original page that caused the `AuthenticationException` (or a custom destination depending on the configuration). + +It's good that you're still here! +Let's now look at how this is configured + +[[cas-client]] +== Configuration of CAS Client +The web application side of CAS is made easy due to Spring Security. +It is assumed you already know the basics of using Spring Security, so these are not covered again below. +We'll assume a namespace based configuration is being used and add in the CAS beans as required. +Each section builds upon the previous section. +A full CAS sample application can be found in the Spring Security xref:samples.adoc#samples[Samples]. + + +[[cas-st]] +=== Service Ticket Authentication +This section describes how to setup Spring Security to authenticate Service Tickets. +Often times this is all a web application requires. +You will need to add a `ServiceProperties` bean to your application context. +This represents your CAS service: + +[source,xml] +---- + + + + +---- + +The `service` must equal a URL that will be monitored by the `CasAuthenticationFilter`. +The `sendRenew` defaults to false, but should be set to true if your application is particularly sensitive. +What this parameter does is tell the CAS login service that a single sign on login is unacceptable. +Instead, the user will need to re-enter their username and password in order to gain access to the service. + +The following beans should be configured to commence the CAS authentication process (assuming you're using a namespace configuration): + +[source,xml] +---- + +... + + + + + + + + + + + +---- + +For CAS to operate, the `ExceptionTranslationFilter` must have its `authenticationEntryPoint` property set to the `CasAuthenticationEntryPoint` bean. +This can easily be done using xref:servlet/appendix/namespace.adoc#nsa-http-entry-point-ref[entry-point-ref] as is done in the example above. +The `CasAuthenticationEntryPoint` must refer to the `ServiceProperties` bean (discussed above), which provides the URL to the enterprise's CAS login server. +This is where the user's browser will be redirected. + +The `CasAuthenticationFilter` has very similar properties to the `UsernamePasswordAuthenticationFilter` (used for form-based logins). +You can use these properties to customize things like behavior for authentication success and failure. + +Next you need to add a `CasAuthenticationProvider` and its collaborators: + +[source,xml,attrs="-attributes"] +---- + + + + + + + + + + + + + + + + + + + + + + +... + +---- + +The `CasAuthenticationProvider` uses a `UserDetailsService` instance to load the authorities for a user, once they have been authenticated by CAS. +We've shown a simple in-memory setup here. +Note that the `CasAuthenticationProvider` does not actually use the password for authentication, but it does use the authorities. + +The beans are all reasonably self-explanatory if you refer back to the <> section. + +This completes the most basic configuration for CAS. +If you haven't made any mistakes, your web application should happily work within the framework of CAS single sign on. +No other parts of Spring Security need to be concerned about the fact CAS handled authentication. +In the following sections we will discuss some (optional) more advanced configurations. + + +[[cas-singlelogout]] +=== Single Logout +The CAS protocol supports Single Logout and can be easily added to your Spring Security configuration. +Below are updates to the Spring Security configuration that handle Single Logout + +[source,xml] +---- + +... + + + + + + + + + + + + + + + + +---- + +The `logout` element logs the user out of the local application, but does not end the session with the CAS server or any other applications that have been logged into. +The `requestSingleLogoutFilter` filter will allow the URL of `/spring_security_cas_logout` to be requested to redirect the application to the configured CAS Server logout URL. +Then the CAS Server will send a Single Logout request to all the services that were signed into. +The `singleLogoutFilter` handles the Single Logout request by looking up the `HttpSession` in a static `Map` and then invalidating it. + +It might be confusing why both the `logout` element and the `singleLogoutFilter` are needed. +It is considered best practice to logout locally first since the `SingleSignOutFilter` just stores the `HttpSession` in a static `Map` in order to call invalidate on it. +With the configuration above, the flow of logout would be: + +* The user requests `/logout` which would log the user out of the local application and send the user to the logout success page. +* The logout success page, `/cas-logout.jsp`, should instruct the user to click a link pointing to `/logout/cas` in order to logout out of all applications. +* When the user clicks the link, the user is redirected to the CAS single logout URL (https://localhost:9443/cas/logout). +* On the CAS Server side, the CAS single logout URL then submits single logout requests to all the CAS Services. +On the CAS Service side, JASIG's `SingleSignOutFilter` processes the logout request by invalidating the original session. + + + +The next step is to add the following to your web.xml + +[source,xml] +---- + +characterEncodingFilter + + org.springframework.web.filter.CharacterEncodingFilter + + + encoding + UTF-8 + + + +characterEncodingFilter +/* + + + + org.jasig.cas.client.session.SingleSignOutHttpSessionListener + + +---- + +When using the SingleSignOutFilter you might encounter some encoding issues. +Therefore it is recommended to add the `CharacterEncodingFilter` to ensure that the character encoding is correct when using the `SingleSignOutFilter`. +Again, refer to JASIG's documentation for details. +The `SingleSignOutHttpSessionListener` ensures that when an `HttpSession` expires, the mapping used for single logout is removed. + + +[[cas-pt-client]] +=== Authenticating to a Stateless Service with CAS +This section describes how to authenticate to a service using CAS. +In other words, this section discusses how to setup a client that uses a service that authenticates with CAS. +The next section describes how to setup a stateless service to Authenticate using CAS. + + +[[cas-pt-client-config]] +==== Configuring CAS to Obtain Proxy Granting Tickets +In order to authenticate to a stateless service, the application needs to obtain a proxy granting ticket (PGT). +This section describes how to configure Spring Security to obtain a PGT building upon thencas-st[Service Ticket Authentication] configuration. + +The first step is to include a `ProxyGrantingTicketStorage` in your Spring Security configuration. +This is used to store PGT's that are obtained by the `CasAuthenticationFilter` so that they can be used to obtain proxy tickets. +An example configuration is shown below + +[source,xml] +---- + + +---- + +The next step is to update the `CasAuthenticationProvider` to be able to obtain proxy tickets. +To do this replace the `Cas20ServiceTicketValidator` with a `Cas20ProxyTicketValidator`. +The `proxyCallbackUrl` should be set to a URL that the application will receive PGT's at. +Last, the configuration should also reference the `ProxyGrantingTicketStorage` so it can use a PGT to obtain proxy tickets. +You can find an example of the configuration changes that should be made below. + +[source,xml] +---- + +... + + + + + + + + +---- + +The last step is to update the `CasAuthenticationFilter` to accept PGT and to store them in the `ProxyGrantingTicketStorage`. +It is important the `proxyReceptorUrl` matches the `proxyCallbackUrl` of the `Cas20ProxyTicketValidator`. +An example configuration is shown below. + +[source,xml] +---- + + + ... + + + + +---- + +[[cas-pt-client-sample]] +==== Calling a Stateless Service Using a Proxy Ticket +Now that Spring Security obtains PGTs, you can use them to create proxy tickets which can be used to authenticate to a stateless service. +The CAS xref:samples.adoc#samples[sample application] contains a working example in the `ProxyTicketSampleServlet`. +Example code can be found below: + +==== +.Java +[source,java,role="primary"] +---- +protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { +// NOTE: The CasAuthenticationToken can also be obtained using +// SecurityContextHolder.getContext().getAuthentication() +final CasAuthenticationToken token = (CasAuthenticationToken) request.getUserPrincipal(); +// proxyTicket could be reused to make calls to the CAS service even if the +// target url differs +final String proxyTicket = token.getAssertion().getPrincipal().getProxyTicketFor(targetUrl); + +// Make a remote call using the proxy ticket +final String serviceUrl = targetUrl+"?ticket="+URLEncoder.encode(proxyTicket, "UTF-8"); +String proxyResponse = CommonUtils.getResponseFromServer(serviceUrl, "UTF-8"); +... +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +protected fun doGet(request: HttpServletRequest, response: HttpServletResponse?) { + // NOTE: The CasAuthenticationToken can also be obtained using + // SecurityContextHolder.getContext().getAuthentication() + val token = request.userPrincipal as CasAuthenticationToken + // proxyTicket could be reused to make calls to the CAS service even if the + // target url differs + val proxyTicket = token.assertion.principal.getProxyTicketFor(targetUrl) + + // Make a remote call using the proxy ticket + val serviceUrl: String = targetUrl + "?ticket=" + URLEncoder.encode(proxyTicket, "UTF-8") + val proxyResponse = CommonUtils.getResponseFromServer(serviceUrl, "UTF-8") +} +---- +==== + +[[cas-pt]] +=== Proxy Ticket Authentication +The `CasAuthenticationProvider` distinguishes between stateful and stateless clients. +A stateful client is considered any that submits to the `filterProcessUrl` of the `CasAuthenticationFilter`. +A stateless client is any that presents an authentication request to `CasAuthenticationFilter` on a URL other than the `filterProcessUrl`. + +Because remoting protocols have no way of presenting themselves within the context of an `HttpSession`, it isn't possible to rely on the default practice of storing the security context in the session between requests. +Furthermore, because the CAS server invalidates a ticket after it has been validated by the `TicketValidator`, presenting the same proxy ticket on subsequent requests will not work. + +One obvious option is to not use CAS at all for remoting protocol clients. +However, this would eliminate many of the desirable features of CAS. +As a middle-ground, the `CasAuthenticationProvider` uses a `StatelessTicketCache`. +This is used solely for stateless clients which use a principal equal to `CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER`. +What happens is the `CasAuthenticationProvider` will store the resulting `CasAuthenticationToken` in the `StatelessTicketCache`, keyed on the proxy ticket. +Accordingly, remoting protocol clients can present the same proxy ticket and the `CasAuthenticationProvider` will not need to contact the CAS server for validation (aside from the first request). +Once authenticated, the proxy ticket could be used for URLs other than the original target service. + +This section builds upon the previous sections to accommodate proxy ticket authentication. +The first step is to specify to authenticate all artifacts as shown below. + +[source,xml] +---- + +... + + +---- + +The next step is to specify `serviceProperties` and the `authenticationDetailsSource` for the `CasAuthenticationFilter`. +The `serviceProperties` property instructs the `CasAuthenticationFilter` to attempt to authenticate all artifacts instead of only ones present on the `filterProcessUrl`. +The `ServiceAuthenticationDetailsSource` creates a `ServiceAuthenticationDetails` that ensures the current URL, based upon the `HttpServletRequest`, is used as the service URL when validating the ticket. +The method for generating the service URL can be customized by injecting a custom `AuthenticationDetailsSource` that returns a custom `ServiceAuthenticationDetails`. + +[source,xml] +---- + +... + + + + + + + +---- + +You will also need to update the `CasAuthenticationProvider` to handle proxy tickets. +To do this replace the `Cas20ServiceTicketValidator` with a `Cas20ProxyTicketValidator`. +You will need to configure the `statelessTicketCache` and which proxies you want to accept. +You can find an example of the updates required to accept all proxies below. + +[source,xml] +---- + + +... + + + + + + + + + + + + + + + + + + + + + +---- diff --git a/docs/modules/ROOT/pages/servlet/authentication/index.adoc b/docs/modules/ROOT/pages/servlet/authentication/index.adoc index bffe96b39e..580bec7355 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/index.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/index.adoc @@ -16,6 +16,7 @@ These sections focus on specific ways you may want to authenticate and point bac * xref:servlet/authentication/passwords/index.adoc#servlet-authentication-unpwd[Username and Password] - how to authenticate with a username/password * xref:servlet/oauth2/login/index.adoc#oauth2login[OAuth 2.0 Login] - OAuth 2.0 Log In with OpenID Connect and non-standard OAuth 2.0 Login (i.e. GitHub) * xref:servlet/saml2/index.adoc#servlet-saml2[SAML 2.0 Login] - SAML 2.0 Log In +* xref:servlet/authentication/cas.adoc#servlet-cas[Central Authentication Server (CAS)] - Central Authentication Server (CAS) Support * xref:servlet/authentication/rememberme.adoc#servlet-rememberme[Remember Me] - how to remember a user past session expiration * xref:servlet/authentication/jaas.adoc#servlet-jaas[JAAS Authentication] - authenticate with JAAS * xref:servlet/authentication/preauth.adoc#servlet-preauth[Pre-Authentication Scenarios] - authenticate with an external mechanism such as https://www.siteminder.com/[SiteMinder] or Java EE security but still use Spring Security for authorization and protection against common exploits. diff --git a/docs/modules/ROOT/pages/servlet/authentication/logout.adoc b/docs/modules/ROOT/pages/servlet/authentication/logout.adoc index fe6f3449c6..1c2061842b 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/logout.adoc @@ -145,5 +145,6 @@ If not configured, a status code 200 is returned by default. - xref:servlet/test/mockmvc/logout.adoc#test-logout[Testing Logout] - xref:servlet/integrations/servlet-api.adoc#servletapi-logout[`HttpServletRequest.logout()`] - xref:servlet/authentication/rememberme.adoc#remember-me-impls[Remember-Me Interfaces and Implementations] +- Documentation for the xref:servlet/appendix/namespace.adoc#nsa-logout[ logout element] in the Spring Security XML Namespace section - xref:servlet/exploits/csrf.adoc#servlet-considerations-csrf-logout[Logging Out] in section CSRF Caveats - Documentation for the xref:servlet/appendix/namespace/http.adoc#nsa-logout[logout element] in the Spring Security XML Namespace section