Tuesday, July 23, 2013

Remember Target URL with Spring Security and Jasig CAS

I recently ran into a unique issue when combining Spring Security, Jasig CAS, and the Ozone Widget Framework (OWF). Basically, the original target URL was not being remembered with all the redirects from the CAS client filters, to CAS server, and then back to Spring Security. For example, if a user browsed to https://localhost/myapp/widget.jsp, they would be redirected to https://localhost/cas. Upon successful login, the user would be incorrectly redirected back to https://localhost/myapp instead of the original target URL.

SavedRequestAwareAuthenticationSuccessHandler almost worked
I thought I had found a solution by using Spring Security's SavedRequestAwareAuthenticationSuccessHandler, and that did work for single requests, but it did not work in an environment like OWF where multiple widgets are loaded simultaneously. The reason is because SavedRequestAwareAuthenticationSuccessHandler extracts the original target URL from the session, which is originally set by the ExceptionTranslationFilter. Since it used the session there could only be one original target URL. So if I had a workspace in OWF that loaded two different widgets from the same WAR (/myapp/widget1.jsp and /myapp/widget2.jsp), then there will only be one target URL saved in the session and both widgets would load the last saved URL, which is obviously not good.

There were several ideas out there, but I really didn't like any of them. Some required you to modify the CAS login form or return some weird javascript in one of the responses. What I wanted was the ability to preserve the original target URL within all the redirect URLs via a parameter. The only problem was, to my knowledge, nothing like this existed in Spring Security. So the following will show you how I extended Spring Security to preserve the original target URL via a parameter. As a simple example, here is a sequence of URLs that I was attempting to support (Note, the URL in the params are typically encoded but I kept them decoded for readability):

  1. User browses to: https://localhost/myapp/widget.jsp
  2. CAS Client filters redirect to: https://localhost/cas/login?service=https://localhost/myapp/j_spring_cas_security_check&spring-security-redirect=/widget.jsp
  3. After authentication user is redirected to the URL defined in the service paramter https://localhost/myapp/j_spring_cas_security_check&spring-security-redirect=/widget.jsp which is monitored by Spring Security.
  4. Once Spring Security does it's thing, it needs to redirect to the value in the spring-security-redirect parameter.
The following example works with Spring Security 3.1.4.RELEASE and Tomcat 7.0.21.
Spring Security Application Context File
What Spring Security example wouldn't be complete without some XML? The following is the application context file (note, for simplicity I have hardcoded the CAS urls and other values, but these would typically be read in from a properties file):

<sec:http use-expressions="true" entry-point-ref="casEntryPoint">
    <sec:intercept-url pattern="/css/**" access="permitAll" />
    <sec:intercept-url pattern="/images/**" access="permitAll" />
    <sec:intercept-url pattern="/scripts/**" access="permitAll" />
    <sec:intercept-url pattern="/**" access="hasRole('ROLE_USER')" requires-channel="https"/>
    
    <sec:custom-filter ref="casFilter" after="CAS_FILTER"/>
</sec:http>

<sec:authentication-manager alias="authenticationManager">
    <sec:authentication-provider ref="casAuthProvider" />
</sec:authentication-manager>

<bean id="casEntryPoint" class="com.example.security.cas.web.RememberCasAuthenticationEntryPoint">
 <property name="loginUrl" value="https://localhost/cas/login" />
 <property name="serviceProperties" ref="serviceProperties" />
 <property name="targetUrlParameter" value="spring-security-redirect" />
</bean>

<bean id="casAuthProvider" class="com.example.security.cas.authentication.RememberCasAuthenticationProvider">
 <property name="userDetailsService" ref="userService" />
 <property name="serviceProperties" ref="serviceProperties" />
 <property name="ticketValidator" ref="ticketValidator" />
 <property name="key" value="an_id_for_this_auth_provider_only" />
 <property name="targetUrlParameter" value="spring-security-redirect" />
</bean>

<!--
 - This is the filter that monitors all incoming requests for the url /myapp/j_spring_cas_security_check (sequence #7).
 - Sets the targetUrlParameter to redirect to the target URL after authentication.
 - Also sets the authenticationDetailsSource to our custom one in order to have access to the HttpServletRequest
 - in RememberCasAuthenticationProvider for ticket validation.
 - Tried setting the authenticationSuccessHandler to SavedRequestAwareAuthenticationSuccessHandler, and that works
 - for a single request, but in OWF if you have two widgets loading different URLs, it doesn't work because
 - it loads the saved url from the session object, so both widgets load the same url.
-->
<bean id="casFilter" class="org.springframework.security.cas.web.CasAuthenticationFilter">
 <property name="authenticationManager" ref="authenticationManager" />
 <property name="authenticationDetailsSource">
  <bean class="com.example.security.web.authentication.RememberWebAuthenticationDetailsSource"/>
 </property>
 <property name="authenticationFailureHandler">
  <bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
   <property name="defaultFailureUrl" value="/cas_failed.jsp" />
  </bean>
 </property>
    <property name="authenticationSuccessHandler">
        <bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler">
            <property name="defaultTargetUrl" value="/" />
            <property name="targetUrlParameter" value="spring-security-redirect" />
        </bean>
    </property>
 <property name="proxyGrantingTicketStorage" ref="proxyGrantingTicketStorage" />
 <property name="proxyReceptorUrl" value="/secure/receptor" />
</bean>

CAS Entry Point
When dealing with Spring Security it all starts with the http entry-point-ref attribute. Here is the code for RememberCasAuthenticationEntryPoint:

package com.example.security.cas.web;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasig.cas.client.util.CommonUtils;

import org.springframework.security.cas.web.CasAuthenticationEntryPoint;

/**
 * Class which is responsible for remembering the original target url specified by the client.
 * Takes the original target url and appends that to the service param used by CAS.
 * This will later be used to redirect to the target URL after authentication.
 */
public class RememberCasAuthenticationEntryPoint extends CasAuthenticationEntryPoint {
    String targetUrlParameter = "spring-security-redirect";
    
    protected String createServiceUrl(final HttpServletRequest request, final HttpServletResponse response) {
        String service = this.serviceProperties.getService();
        
        String servletPath = request.getServletPath();        
        if (servletPath) {
            service += String.format("?%s=%s", this.targetUrlParameter, servletPath);
        }
        
        return CommonUtils.constructServiceUrl(null, response, service, null, this.serviceProperties.getArtifactParameter(), this.encodeServiceUrlWithSessionId);
    }
}

CAS Authentication Provider
Next up is the CAS authentication provider which we have defined as RememberCasAuthenticationProvider. This bean is given to the CAS Custom Filter under the alias authenticationManager. Here is the code for RememberCasAuthenticationProvider:

package com.example.security.cas.authentication;

import com.example.security.web.authentication.RememberWebAuthenticationDetails;

import org.jasig.cas.client.validation.Assertion;
import org.jasig.cas.client.validation.TicketValidationException;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
import org.springframework.security.cas.authentication.CasAuthenticationToken;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.cas.web.CasAuthenticationFilter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.*;

/**
 * CasAuthenticationProvider that tries to remember the original target url requested by the client.
 * The trick is having access to the HttpServletRequest in the authenticateNow() method.
 * This is accomplished via the RememberWebAuthenticationDetails class.
 * Since authenticateNow() was marked as private in CasAuthenticationProvider I had to also override
 * the authenticate() method. Created spring security jira https://jira.springsource.org/browse/SEC-2188
 * to address making authenticateNow protected so we don't have to duplicate authenticate().
 */
public class RememberCasAuthenticationProvider extends CasAuthenticationProvider {

    UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();
    ServiceProperties serviceProperties;
    GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
    String targetUrlParameter = "spring-security-redirect";

    /**
     * Straight copy and paste from CasAuthenticationProvider
     * @see spring security jira https://jira.springsource.org/browse/SEC-2188
     */
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
    }

    /**
     * The service URL used in ticketValidator.validate() needs to match the service URL given to CAS when
     * the ticket was granted.
     */
    protected CasAuthenticationToken authenticateNow(final Authentication authentication) throws AuthenticationException {
        try {
            String targetPath = this.getTargetPath(authentication.getDetails());
        
            def service = String.format("%s?%s", serviceProperties.getService(), targetPath);
            
            final Assertion assertion = this.ticketValidator.validate(authentication.getCredentials().toString(), service);
            final UserDetails userDetails = loadUserByAssertion(assertion);
            userDetailsChecker.check(userDetails);
            return new CasAuthenticationToken(this.key, userDetails, authentication.getCredentials(),
                    authoritiesMapper.mapAuthorities(userDetails.getAuthorities()), userDetails, assertion);
        } catch (final TicketValidationException e) {
            throw new BadCredentialsException(e.getMessage(), e);
        }
    }
    
    /**
     * Extracts the original target url form the query string.
     * Example query string: spring-security-redirect=/widget.jsp&ticket=ST-112-RiRTVZmzghHO7az5gpJF-cas
     */
    protected String getTargetPath(Object authenticationDetails) {
        String targetPath = "";
        
        if (authenticationDetails instanceof RememberWebAuthenticationDetails) {
            RememberWebAuthenticationDetails details = (RememberWebAuthenticationDetails) authenticationDetails;
            String queryString = details.getQueryString();
            
            if (queryString) {
                int start = queryString.indexOf(this.targetUrlParameter);
                if (start >= 0) {
                    int end = queryString.indexOf("&", start);
                    if (end >= 0) {
                        targetPath = queryString.substring(start, end);
                    } else {
                        targetPath = queryString.substring(start);
                    }
                }
            }
        }
        
        return targetPath;
    }
}

Authentication Details Source
Since I needed access to the requests query string in the RememberCasAuthenticationProvider.getTargetPath() method, I needed to provide a different WebAuthenticationDetails class. This was accomplished by setting the authenticationDetailsSource property on the CAS Filter. Here is the code for RememberWebAuthenticationDetailsSource:

package com.example.security.web.authentication;

import javax.servlet.http.HttpServletRequest;

import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.web.authentication.WebAuthenticationDetails

public class RememberWebAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {

    public WebAuthenticationDetails buildDetails(HttpServletRequest request) {
        return new RememberWebAuthenticationDetails(request);
    }
}

Here is the code for RememberWebAuthenticationDetails:

package com.example.security.web.authentication;

import javax.servlet.http.HttpServletRequest;

import org.springframework.security.web.authentication.WebAuthenticationDetails;

public class RememberWebAuthenticationDetails extends WebAuthenticationDetails {
    private final String queryString;

    public RememberWebAuthenticationDetails(HttpServletRequest request) {
        super(request);
        
        this.queryString = request.getQueryString();
    }
    
    public String getQueryString() {
        return this.queryString;
    }
}

Summary
That pretty much does it. I know it's a lot of code, but once I got familiar with Spring Security it really wasn't that much. Once you get this all configured it should just work and the users original target URL can be seen when getting redirected to the CAS login page and then after authentication, the user should be redirected back to the original target URL. And in an OWF environment where multiple URLs from the same WAR are being loaded simultaneously this solution seems to work.

I do want to mention that we have since noticed that there are conditions where the entire URL is not remembered. Simple URLs like /myapp/widget.jsp work great, but REST URLs like /myapp/api/events/1 are not completely preserved. Nor are params remembered either like /myapp/widget.jsp?id=1. At this time we really don't need that capability but I don't think it would be hard to add it. Most of the work has already been done.

One other thing. I think this experiment begs the question: why doesn't Spring Security already support something like this out of the box? It would seem like a very common use case. While researching it seems there might be a reluntance to support this feature due to security concerns of a malicious user gaining access to a resource they are not authorized for. I didn't do super extensive testing, but in the testing I did do, I was not able to gain access to resources I was not authorized for. Perhaps when/if I come back to these classes and add support for the noted issues above I might submit a patch back to Spring Security so this can get incorporated into Spring Security.