Spring / CAS with unprotected pages.

Tags:

The problem

One problem with using Spring Security and CAS is that Spring Security will only check to see if the user is logged in when the user requests a page that is protected by Spring Security. Assume have 2 web applications (webAppA and webAppB) that are protected by Spring Security and use CAS in order to handle the authentication. On the pages of each web application you have a link, if:

  • The user is logged in, you want to show a logout link;
  • The user is not logged in, you want to show the login link.

The problem is this:

  1. You go to webAppA and browse the site;
  2. You log into webAppA via CAS;
  3. You continue to browse webAppA andf it shows the login/logout link correctly;
  4. You go to a non Spring Security protected page in webAppB but you find that it only shows the default link (probably login) even though you did login into webAppA. You really want to be able to see the logout link in this case.

The solution

I couldn't find anything about solving this but the following link is useful in getting an overview of what needs to happen when authenticating with CAS from Spring Security.

I came up with the following solution which involves adding an extra filter to the Spring Security file and a modification to the Spring Security CasProcessingFilterEntryPoint class. Firstly, add this to your applicationContext-security.xml file:

<bean id="CASUnprotectedPageFilter" class="your.package.security.spring.cas.CASUnprotectedPageFilter">
<property name="ignoreFilesOfType" value="css,js,gif,jpg,png,swf,pdf,ico" />
<property name="loginPage" value="login!goToLogin" />
<property name="doNotRunFor" value="/services/WebService , /unsecured/error.action" />
<sec:custom-filter after="SWITCH_USER_FILTER"/>
</bean>

The above filter has several properties:

The Java class CASUnprotectedPageFilter is as follows:

The CASUnprotectedPageFilter class uses a session parameter called USER_ACCOUNT_DISABLED. You should check that your application places this in the session if your user has an account that is disabled. If you don't use this then the filter should still work OK but an attempt will be made to login via CAS when it is not required. (See lines ~142-148)

package uk.co.prodia.prosoc.security.spring.cas;

import java.util.HashSet;
import java.util.StringTokenizer;
import java.util.ArrayList;

import java.io.File;
import java.io.IOException;

import javax.servlet.ServletContext;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.ServletException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;

import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpServletRequest;

import org.springframework.web.context.support.WebApplicationContextUtils;

import org.springframework.context.ApplicationContext;

import org.springframework.security.AuthenticationCredentialsNotFoundException;

import org.springframework.security.context.SecurityContextImpl;
import org.springframework.web.context.ServletContextAware;

import org.springframework.security.userdetails.User;

import org.springframework.security.ui.cas.ServiceProperties;

import org.springframework.security.providers.cas.CasAuthenticationToken;

import org.apache.log4j.Logger;

/**
 * <p>TODO: This filter queries CAS heavily for every request (twice each time). This will lag performace and so it may be sensible for it
 * to detect when a URL is not protected by spring-security first before firing the requests to CAS. I am not sure how to do that
 * though.</p>
 *
 * <p>For pages that are not secured by spring-security it is not always possible to tell if the user is logged in. If the user logs in
 * inside of one context, and then goes to an unsecured page in another context, then the application will not know whether or not the
 * user is logged in. This fixes that by checking for a valid login for every request.</p>
 *
 * <p>This filter will not process requests under the following circumstances:</p>
 *
 * <ul>
 *  <li>If the filetype of the requested file has been passed in, as a comma seperated list, to the setIgnoreFilesOfType(String args);</li>
 *  <li>If the requested URL is the same as setLoginPage(args)</li>
 *  <li>If this is a POST request. This restriction is so that when a form is posted the request is not re-directed to CAS. If
 *  this was allowed to happen then when CAS processed the request and returned us to the page we wanted to go to, we would have
 *  lost the POST data. If this is not desirable then this method could add the posted paramters to the return URL from CAS. This
 *  would have to be done as a GET request though and so would be limited to 256 characters of data. This had not been programmed
 *  in yet and would naturally prevent file uploads and would fail for posted data over the 256 character limit.</li>
 * </ul>
 *
 * <p><strong>See /WEB-INF/applicationContext-security.xml</strong></p>
 *
 * @author doahh
 */

/*
 * ===================================================================================================
 * IMPORTANT: DEVELOPERS READ BEFORE EDITING.
 * ===================================================================================================
 * This class will be copied to the other services and so needs to work with the class files/jars provided by that service. Do not use
 * custom class that will not be copied to the other services.
 */
public class CASUnprotectedPageFilter implements Filter , ServletContextAware , java.io.Serializable {

    private static final Logger logger = Logger.getLogger(CASUnprotectedPageFilter.class);
    private ServletContext servletContext;
    private String ignoreFilesOfType; // Inputed from applicationContext-security.xml
    private HashSet<String> ignoreFileTypes = new HashSet<String>(); // The HashSet of the parameter taken from variable ignoreFilesOfType
    private String doNotRunFor;
    private ArrayList<String> doNotRunForList = new ArrayList<String>();

    // The URL of the login page as used within the ROOT context. If this filter is not running in the root context this has no effect.
    private String loginPage;
   
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        /* We need this but I don't really understand the reason why. It is used to prevent processing continuing even after a call to
         * a method that does a redirect. I would have thought the redirect stop processing of a method from continuing but
         * that doesn't seem to be the case. When this filter redirects to cas, this filter continues with processing the rest of
         * the method including the call to chain.doFilter(request , response). As processing continues the action called is run
         * when we do the first redirect to cas to get a ticket and then again on the second redirect to cas when we validate the
         * ticket. That means any method get run twice, not a good idea. This boolean stops that.
         */
        boolean processFilterChain = true;

        if(request instanceof HttpServletRequest){
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            HttpSession session = httpRequest.getSession(true);
            logger.debug("requestedURI [" + httpRequest.getRequestURI() +"]");

            // Get the springContext from the session. It will be there if we have logged in via CAS at any time (it will be there even if we logged off)
            SecurityContextImpl springSecurityContext = (SecurityContextImpl) session.getAttribute("SPRING_SECURITY_CONTEXT");

            /* If the user has already logged in then they will have a username in the Spring principal object
             * and so there is no point in running this filter.
             */
//            Authentication authentication = null;
//            if(springSecurityContext != null) authentication = springSecurityContext.getAuthentication();
//            if(authentication == null || ! authentication.isAuthenticated()){

//                logger.debug("User was not already authenticated in this context/subcontext so checking with CAS to see if they are logged in");

                // Find out the file type that was called. There is no point asking CAS if we are logged in when we only want, for example,
                // a stylesheet, image or pdf
                String fileType = "";
                String requestURL = httpRequest.getRequestURL().toString();
                logger.debug("requestURL [" + requestURL +"]");
                int lastIndexOfFileSeperator = requestURL.lastIndexOf(File.separator);
                String fileTypeWithFileName = requestURL.substring(lastIndexOfFileSeperator + 1 , requestURL.length());
                int lastIndexOfDot = fileTypeWithFileName.lastIndexOf(".");
                if(lastIndexOfDot != -1) fileType = fileTypeWithFileName.substring(lastIndexOfDot  + 1 , fileTypeWithFileName.length());

                // IMPORTANT: DO NOT CHANGE THE ORDERS OF THESE, keep all the 'runFilter = true' at the top
                boolean runFilter = false;
                if(fileType.equalsIgnoreCase("")) runFilter = true; // A blank filetype could be www.domain.com or domain.com/helloworld
                if( ! ignoreFileTypes.contains(fileType)) runFilter = true; // Not a filetype to ignore as defined in the applicationContext-security.xml
                if(requestURL.indexOf(loginPage) != -1) runFilter = false; // Do not run if this is the login page URL
                if(httpRequest.getMethod().equalsIgnoreCase("POST")) runFilter = false; // Do not run for POST methods as we loose data on sending a GET to CAS
                for(String doNotRunFor : doNotRunForList){ // Do not run for urls in the doNotRunForList
                    if(requestURL.indexOf(doNotRunFor) != -1){
                        runFilter = false;
                        break;
                    }
                }
                /* If the user tried to login with a disabled account then do not check if they are logged in again as they will always be sent to the error page
                 * even if they click on the home page link: i.e. they wil not be able to use the site without deleting the session cookie.
                 */
                if(httpRequest.getSession().getAttribute("SPRING_SECURITY_LAST_EXCEPTION") instanceof org.springframework.security.DisabledException) runFilter = false;
                /* If the PROSOC_USER_ACCOUNT_DISABLED is in the session then we don't want to run this filter as it will try to log in,
                 * cas will tell Spring Security that the account is logged and send the user to the error page. THis means they will always
                 * be sent to the error page even if they then want to access unsecured pages. The PROSOC_USER_ACCOUNT_DISABLED is
                 * placed in, and removed from the session, by the invalidateSessionOnSpringDisabledExceptionFilter.
                 * See applicationContext-security.xml for details.
                 */
                if(httpRequest.getSession().getAttribute("PROSOC_USER_ACCOUNT_DISABLED") != null) runFilter = false;
                // END IMPORTANT: DO NOT CHANGE THE ORDERS OF THESE

                if(runFilter){

                    logger.debug("Running filter with filetype [" + fileType +"]");

                    if(session != null){
                        boolean returnFromCas = new Boolean((String) httpRequest.getParameter("returnFromCas")).booleanValue();
                        boolean loggedInViaCas = new Boolean((String) httpRequest.getParameter("loggedInViaCAS")).booleanValue();

                        logger.debug("returnFromCas [" + returnFromCas +"]");
                        logger.debug("loggedInViaCas [" + loggedInViaCas +"]");

                        if(springSecurityContext == null){
                            springSecurityContext = new SecurityContextImpl();
                            session.setAttribute("SPRING_SECURITY_CONTEXT" , springSecurityContext);
                            logger.debug("Created new springSecurityContext and added it to the session under key SPRING_SECURITY_CONTEXT");
                            logger.debug("springSecurityContext [" + session.getAttribute("SPRING_SECURITY_CONTEXT") +"]");
                        }

                        CasAuthenticationToken casAuthenticationToken = null;
                        if(springSecurityContext != null){
                            // Get the user form the springSecurityContext, the user will only be there if they are activly logged on through CAS
                            User user = null;
                            if(springSecurityContext != null){
                                casAuthenticationToken = (CasAuthenticationToken) springSecurityContext.getAuthentication();
                                logger.debug("casAuthenticationToken [" + casAuthenticationToken +"]");

                                if(casAuthenticationToken != null){
                                    user = (User) casAuthenticationToken.getPrincipal();
                                }
                            }

                            logger.debug("user [" + user +"] <= null is OK");

                            // Allows access to beans defined in spring config files
                            ApplicationContext applicationContext = WebApplicationContextUtils.getWebApplicationContext(this.servletContext); // ServletContext may be a SpringServletContext
                            logger.debug("applicationContext [" + applicationContext +"]");

                            // Get the beans we need. They are defined in /WEB-INF/applicationContext-security.xml
                            ServiceProperties serviceProperties = (ServiceProperties) applicationContext.getBean("serviceProperties");
                            CasProcessingFilterEntryPoint cpfep = (CasProcessingFilterEntryPoint) applicationContext.getBean("casProcessingFilterEntryPoint");

                            String originalServiceURL = serviceProperties.getService();

                            // First time into the page but only if we have already logged into CAS
                            // through either this or another service. This will get us a ticket from CAS
                            logger.debug("user [" + user +"]");
                            if(user == null && !returnFromCas && !loggedInViaCas){

                                // Get the address of this page
                                String file = httpRequest.getRequestURI();
                                if (httpRequest.getQueryString() != null) file += '?' + httpRequest.getQueryString();
                                String thisUrl = new java.net.URL(httpRequest.getScheme() , httpRequest.getServerName() , httpRequest.getServerPort() , file).toString();

                                // If not logged in then we want to use this. It will not thow an error and is always OK but gives a 'ticket'
                                // parameter if logged into CAS.
                                String queryStringSeperator;
                                if(thisUrl.indexOf("returnFromCas=true") == -1){
                                    if(thisUrl.indexOf("?") == -1) queryStringSeperator = "?"; // Are we adding the only parameter
                                    else queryStringSeperator = "&"; // or to an already existing parameter list

                                    serviceProperties.setService(thisUrl + queryStringSeperator +"returnFromCas=true");
                                    logger.debug("1. service [" + serviceProperties.getService() +"]");
                                }

                                cpfep.setUseCASGateway(true); // Tell CAS NOT to show us the login page if we are not logged in.
                                cpfep.commence(request, response, new AuthenticationCredentialsNotFoundException("Authentication credentials not found from CASUnprotectedPageFilter class."));
                                cpfep.setUseCASGateway(false); // Set this back as we only want CAS to act as a gateway on this request

                                // IMPORTANT: see description where this variable (processFilterChain) is defined for its purpose. I am not sure why it is needed but it is.
                                processFilterChain = false;
                            }

                            // Returning from CAS possibly with a ticket
                            logger.debug("ticket [" + request.getParameter("ticket") +"]");
                            if(user == null && request.getParameter("ticket") != null && !loggedInViaCas && returnFromCas){

                                // Throws an error if we are not logged in but if we are logged in then we will have a ticket
                                if(originalServiceURL.indexOf("loggedInViaCAS=true") == -1) serviceProperties.setService(serviceProperties.getService() + "?loggedInViaCAS=true");
                                logger.debug("2. service [" + serviceProperties.getService() +"]");
                                cpfep.setUseCASGateway(true); // Tell CAS NOT to show us the login page if we are not logged in.
                                cpfep.commence(request, response, new org.springframework.security.AuthenticationCredentialsNotFoundException("Authentication object not found."));
                                cpfep.setUseCASGateway(false); // Set this back as we only want CAS to act as a gateway on this request

                                // IMPORTANT: see description where this variable (processFilterChain) is defined for its purpose. I am not sure why it is needed but it is.
                                processFilterChain = false;
                            }

                            // This must be set back with loggedInViaCas=true
                            if(originalServiceURL.indexOf("loggedInViaCAS=true") == -1){
                                String queryStringSeperator;
                                if(originalServiceURL.indexOf("?") == -1) queryStringSeperator = "?"; // Are we adding the only parameter
                                    else queryStringSeperator = "&"; // or to an already existing parameter list
                                serviceProperties.setService(originalServiceURL + queryStringSeperator + "loggedInViaCAS=true");
                            } else {
                                serviceProperties.setService(originalServiceURL);
                            }
                        }
                    }
                }
//            }
        }

        // Check if we did a redirect to cas or not during this request.
        // IMPORTANT: see description where this variable (processFilterChain) is defined for its purpose. I am not sure why it is needed but it is.
        if(processFilterChain){
            // Continue on with the chain
            logger.debug("PROCESSING chain.doFilter(..)");
            chain.doFilter(request , response);
        }
    }
   
    public void init(FilterConfig filterConfig) {
        this.servletContext = filterConfig.getServletContext();
    }

    public void setServletContext(ServletContext servletContext){
        this.servletContext = servletContext;
    }
   
    public void destroy() {}

    public String getIgnoreFilesOfType() {
        return ignoreFilesOfType;
    }

    /**
     * If a file of the type given here is processed through the filter then the filter will not
     * ask CAS if the user is logged in. This is because we do not need to know if the user
     * if logged into CAS for certain types of file to be served up to them. If the user's browser
     * requests a css or javascript file then they can just have it, they don't need to be logged in.
     * We can't do this the other way and allow processing for files of type, for example .jspf as
     * the .jspf files will be included by the server and will not be send to the filter except as part
     * of the whole page request.
     *
     * @param ignoreFilesOfType
     */
    public void setIgnoreFilesOfType(String ignoreFilesOfType) {
        this.ignoreFilesOfType = ignoreFilesOfType;
       
        StringTokenizer stringTokenizer = new StringTokenizer(ignoreFilesOfType , ",");
        while(stringTokenizer.hasMoreTokens()){
            ignoreFileTypes.add(stringTokenizer.nextToken());
        }
       
        logger.debug("Will ignore files of type [" + ignoreFileTypes +"]. If empty, filter will run for all filetypes.");
    }

    /**
     * Returns the value set in applicationContext-security.xml
     *
     * @return The login page of CAS.
     */
    public String getLoginPage() {
        return loginPage;
    }

    /**
     * This parameter should be set from the applicationContext-security.xml file and represents a path that this filter will
     * not run for. The test used is "requestURL.indexOf(loginPage) == -1", if true then this filter will not contact CAS.
     *
     * @param loginPage
     */
    public void setLoginPage(String loginPage) {
        this.loginPage = loginPage;
        logger.debug("Will not run filter for path [" + loginPage +"]. If empty, filter will run for all file paths.");
    }

    public String getDoNotRunFor() {
        return doNotRunFor;
    }

    /**
     * <p>A list of files that this filter should not run for. Internally this method uses a StringTokenizer and so
     * the list may be seperated in any of the standard ways that a StringTokenizer splits upon. This list is generally
     * populated from the /WEB-INF/applicationContext-security.xml file.</p>
     *
     * <p>The test for deciding if the filter should run for items in the list is:</p>
     *
     * <ul>
     *  <li>requestURL.indexOf(doNotRunFor) != -1</li>
     * </ul>
     *
     * <p>so you should be careful that the match is accurate enough and will not match other URLs that you
     * do not want to be excluded form this filter, e.g: setting a value of /admin/ in the
     * applicationContext-security.xml will mean this filter will not run for anything containing the
     * string <em>admin</em> in the URL.</p>
     *
     * @param doNotRunFor
     * @see java.util.StringTokenizer
     */
    public void setDoNotRunFor(String doNotRunFor) {

        StringTokenizer stringTokenizer = new StringTokenizer(doNotRunFor);
        String thisToken;
        while(stringTokenizer.hasMoreTokens()){
            thisToken = (String) stringTokenizer.nextElement();
            doNotRunForList.add(thisToken);
        }

        logger.debug("doNotRunForList [" + doNotRunForList +"]");

    }
}

You will also need the edited CasProcessingFilterEntryPoint class:

 package uk.co.prodia.prosoc.security.spring.cas;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;

import javax.servlet.http.HttpServletResponse;

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

import org.springframework.security.AuthenticationException;

import org.apache.log4j.Logger;

/**
 * <p>This class contains an edited method [commence(final ServletRequest servletRequest, final ServletResponse servletResponse,
 * final AuthenticationException authenticationException)] from the spring frameworks CasProcessingFilterEntryPoint.
 * This was necessary as it was not possible to set the CAS parameter 'gateway=true' in the current version of Spring.<p>
 *
 * <p>The gateway=true parameter tells CAS whether or not to send the user to the login page if the user is not logged in.
 * When this parameter is true CAS will NOT send the user to the CAS login page if they are not logged in. They will
 * instead be returned to the page defined in the 'service' parameter which is generally the page that the user
 * first requested.</p>
 *
 * @author gavin
 */
public class CasProcessingFilterEntryPoint extends org.springframework.security.ui.cas.CasProcessingFilterEntryPoint {

    private static final Logger logger = Logger.getLogger(CasProcessingFilterEntryPoint.class);
    private boolean encodeServiceUrlWithSessionId = true;
    private boolean useCASGateway = false;
    
    @Override
    public void commence(final ServletRequest servletRequest, final ServletResponse servletResponse,
            final AuthenticationException authenticationException) throws IOException, ServletException {

        logger.debug("Entered CasProcessingFilterEntryPoint.commence(...)");
        
        final HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
        final String urlEncodedService = CommonUtils.constructServiceUrl(null,
                                                                        httpResponse,
                                                                        getServiceProperties().getService(),
                                                                        null,
                                                                        "ticket",
                                                                        this.encodeServiceUrlWithSessionId);
        
        
        logger.debug("useCASGateway [" + useCASGateway +"]");
        logger.debug("urlEncodedService [" + urlEncodedService +"]");
        
        String redirectUrl = CommonUtils.constructRedirectUrl(getLoginUrl(),
                                                                    "service",
                                                                    urlEncodedService,
                                                                    getServiceProperties().isSendRenew(),
                                                                    useCASGateway);
        
        logger.debug("redirectUrl [" + redirectUrl +"]");
        
        /* THIS NO LONGER SEEMS TO BE TRUE SO GOING BACK TO SIMPLE sendRedirect(.)
         * Doing it this way doesn't seem to cause any problems and solves an issue
         * with response.sendRedirect where a service may have an index page that makes the same call.
         * It is not possible to make two sendRedirect(.) calls one after the other.
         */
        httpResponse.sendRedirect(redirectUrl);
//        httpResponse.setStatus(301);
//        httpResponse.addHeader("Location", redirectUrl);
//        httpResponse.setHeader( "Connection", "close" );
    }

    /**
     * Whether or not this class is using the 'gateway=true' parameter for CAS
     *
     * @return true if this class if using the gateway=true parameter, false otherwise
     */
    public boolean isUseCASGateway() {
        return useCASGateway;
    }

    /**
     * Set wheter or not this class should use the 'gateway=true' parameter when communicating with CAS.
     *
     * @param useCASGateway
     */
    public void setUseCASGateway(boolean useCASGateway) {
        this.useCASGateway = useCASGateway;
    }
            
}

The CasUnprotectedPageFilter class is quite large and so I won't cover it workings here. It is fairly well commented so hopefully you can work out what is going on if you really need to. One thing I will say about it is that, when it runs, it will:

  1. Go to CAS;
  2. CAS will return processing to this filter with a query string parameter called returnFromCas which is provided by this filter;
  3. The filter will then go back to CAS which will again return to this filter with a query string parameter called ticket which is provided by CAS itself;

So there is a bit of recursion going on in here. Just so you know if you need to debug it.

Finally, the filter should probably be split into two separate filters, one for each time it goes to CAS. This would make it easier to conceptualise but I haven't done that yet and don't have any plans to do so in the near future.

Comments:

that filter changes global object serviceProperties. I think, If many client access that filter there must be problem. Or not?

It calls the setService method which as far as I know simply sets the return address where CAS will return to once the call to authenticate is completed. It hasn't caused me any problems so far but I am not using it in production. I seem to remember that this was necessary because Spring Security didn't have support for the 'gateway=true' parameter but that may have changed in more recent versions of Spring. You may want to check out the source of ServiceProperties (I think) and see if the gateway parameter that you pass in is actually used.

Post a Comment:

HTML Syntax: Allowed