Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

Motivation

We wanted to be more flexible in the use of the login UI, so e.g. wanted to embed it in several places as a small panel. Moreover, we wanted to understand CAS as a pure service, not having to maintain layout information twice. Also, we wanted to support both, login postings from another site and direct login at the CAS server as a fallback.

Other proposed solutions

Proposed Solutions that do not work 

...

Here is the static remote login form we use:

Code Block

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1">
<title>Test remote Login using JS</title>
<script type="text/javascript">
function prepareLoginForm() {
	$('myLoginForm').action = casLoginURL;
	$("lt").value = loginTicket;
}

function checkForLoginTicket() {
	var loginTicketProvided = false;
	var query	= '';
    	casLoginURL = 'https://pcdt057:8443/cas/login';
    	thisPageURL = 'https://pcdt057:8443/cas/test-remote-login-js+error.html';
    	casLoginURL += '?login-at=' + encodeURIComponent (thisPageURL);

	query	= window.location.search;
	query	= query.substr (1);


	var param	= new Array();
	//var value	= new Array();
	var temp	= new Array();
	param	= query.split ('&');

	i = 0;
	while (param[i]) {
		temp		= param[i].split ('=');
		if (temp[0] == 'lt') {
			loginTicket = temp[1];
			loginTicketProvided = true;
		}
		if (temp[0] == 'error_message') {
        	    error = temp[1];
        	}
		i++;
	}
	if (!loginTicketProvided) {
		location.href = casLoginURL + '&get-lt=true';
	}
}

function $(id) {
	return document.getElementById(id);
}
var loginTicket;
var error;
var casLoginURL;
var thisPageURL;

checkForLoginTicket();
onload = prepareLoginForm;
</script>
</head>
<body>
<h2>Test remote Login using JS</h2>
<form id="myLoginForm" action="" method="post">
<input type="hidden" value="submit" name="_eventId"/>
<table>
<tr>
    <td id="txt_error" colspan="2">

	<script type="text/javascript" language="javascript">
	<!--
	if ( error ) {
		error = decodeURIComponent (error);
		document.write (error);
	}
	//-->
	</script>

	</td>
</tr>
<tr>
	<td>Benutzer:</td>
	<td><input type="text" value="konrad" name="username" ></td>
</tr>
<tr>
	<td>Password:</td>
	<td><input type="text" value="konrad" name="password" ></td>
</tr>
<tr>
	<td>Login Ticket:</td>
	<td><input type="text" name="lt" id="lt" value=""></td>
</tr>
<tr>
	<td>Service:</td>
	<td><input type="text" name="service" value="http://quiz.jacomac.de"></td>
</tr>
<tr>
	<td align="right" colspan="2"><input type="submit" /></td>
</tr>
</table>
</form>
</body>
</html>

 

CAS side

Adapting only the views and introducing if-else statements there meant having logic that belongs to the controllers in the view. Therefore we decided to inject our add-on into the spring web flow. Here are the changes we have made to login-webflow.xml (changes highlighted in blue):

Code Block

<?xml version="1.0" encoding="UTF-8"?>
<flow xmlns="http://www.springframework.org/schema/webflow"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
              http://www.springframework.org/schema/webflow
              http://www.springframework.org/schema/webflow/spring-webflow-1.0.xsd">

 

<start-state idref="provideLoginTicketToRemoteRequestor" />

<action-state id="provideLoginTicketToRemoteRequestor">
<action bean="provideLoginTicketToRemoteRequestorAction" />
<transition on="loginTicketRequested" to="viewRedirectToRequestor" />
<transition on="continue" to="automaticCookiePathSetter" />
</action-state>

<view-state id="viewRedirectToRequestor" view="bmRedirectToRequestorView">
<transition on="submit" to="bindAndValidate" />
</view-state>

Code Block

<action-state id="automaticCookiePathSetter">
        <action bean="automaticCookiePathSetterAction" />
        <transition on="success"
            to="ticketGrantingTicketExistsCheckAction" />
    </action-state>

    <action-state id="ticketGrantingTicketExistsCheckAction">
        <action bean="ticketGrantingTicketExistsAction" />
        <transition on="ticketGrantingTicketExists"
            to="hasServiceCheck" />
        <transition on="noTicketGrantingTicketExists"
            to="gatewayRequestCheck" />
    </action-state>

    <action-state id="gatewayRequestCheck">
        <action bean="gatewayRequestCheckAction" />
        <transition on="gateway" to="redirect" />
        <transition on="authenticationRequired" to="viewLoginForm" />
    </action-state>

    <action-state id="hasServiceCheck">
        <action bean="hasServiceCheckAction" />
        <transition on="authenticatedButNoService"
            to="viewGenericLoginSuccess" />
        <transition on="hasService" to="renewRequestCheck" />
    </action-state>

    <action-state id="renewRequestCheck">
        <action bean="renewRequestCheckAction" />
        <transition on="authenticationRequired" to="viewLoginForm" />
        <transition on="generateServiceTicket"
            to="generateServiceTicket" />
    </action-state>

    <!--
        <action-state id="startAuthenticate">
        <action bean="x509Check" />
        <transition on="success" to="sendTicketGrantingTicket" />
        <transition on="error" to="viewLoginForm" />
        </action-state>
    -->
    <view-state id="viewLoginForm" view="casLoginView">
        <transition on="submit" to="bindAndValidate" />
    </view-state>

    <action-state id="bindAndValidate">
        <action bean="authenticationViaFormAction" />
        <transition on="success" to="submit" />
        <transition on="error" to="viewLoginForm" />
    </action-state>

    <action-state id="submit">
        <action bean="authenticationViaFormAction" method="submit" />
        <transition on="warn" to="warn" />
        <transition on="success" to="sendTicketGrantingTicket" />
        <transition on="error" to="viewLoginForm" />

 

<transition on="errorForRemoteRequestor" to="viewRedirectToRequestor" />

Code Block

</action-state>

    <action-state id="sendTicketGrantingTicket">
        <action bean="sendTicketGrantingTicketAction" />
        <transition on="success" to="serviceCheck" />
    </action-state>

    <action-state id="serviceCheck">
        <action bean="hasServiceCheckAction" />
        <transition on="authenticatedButNoService"
            to="viewGenericLoginSuccess" />
        <transition on="hasService" to="generateServiceTicket" />
    </action-state>

    <action-state id="generateServiceTicket">
        <action bean="generateServiceTicketAction" />
        <transition on="success" to="warn" />
        <transition on="error" to="viewLoginForm" />
        <transition on="gateway" to="redirect" />
    </action-state>

    <!--
        The "warn" action makes the determination of whether to redirect directly to the requested
        service or display the "confirmation" page to go back to the server.
    -->
    <action-state id="warn">
        <action bean="warnAction" />
        <transition on="redirect" to="redirect" />
        <transition on="warn" to="showWarningView" />
    </action-state>

    <!--
        the "viewGenericLogin" is the end state for when a user attempts to login without coming directly from a service.
        They have only initialized their single-sign on session.
    -->
    <end-state id="viewGenericLoginSuccess"
        view="casLoginGenericSuccessView" />


    <!--
        The "showWarningView" end state is the end state for when the user has requested privacy settings (to be "warned") to be turned on.  It delegates to a
        view defines in default_views.properties that display the "Please click here to go to the service." message.
    -->
    <end-state id="showWarningView" view="casLoginConfirmView" />

    <!--
        The "redirect" end state allows CAS to properly end the workflow while still redirecting
        the user back to the service required.
    -->
    <end-state id="redirect"
        view="externalRedirect:${externalContext.requestParameterMap['service']}${requestScope.ticket == null ? '' : (externalContext.requestParameterMap['service'].indexOf('?') != -1 ? '&amp;' : '?') + 'ticket=' + requestScope.ticket}" />

    <global-transitions>
        <transition to="viewServiceErrorView"
            on-exception="org.jasig.cas.services.UnauthorizedServiceException" />
    </global-transitions>
</flow>

 

 Problems encountered and suggested solutions

The solution works well, but it has a very dirty edge: introducing a new event in the submit method that allows us to distinguish between a login posting from the CAS Site and another site. Here we had to redeclare the submit method in org.jasig.cas.web.flow.AuthenticationViaFormAction (changes marked in blue):

Code Block

[...]         try {
            final String ticketGrantingTicketId = this.centralAuthenticationService
                .createTicketGrantingTicket(credentials);
            ContextUtils.addAttribute(context,
                AbstractLoginAction.REQUEST_ATTRIBUTE_TICKET_GRANTING_TICKET,
                ticketGrantingTicketId);
            setWarningCookie(response, warn);
            return success();
        } catch (final TicketException e) {
            populateErrorsInstance(context, e);

            // START: ChangesBusinessMart: check, whether the posting has been sent from a remote server
            String myServerName = request.getLocalName();
            String referrer = request.getParameter("login-at");
            if (referrer != null && referrer.indexOf(myServerName) == -1) {
                return result("errorForRemoteRequestor");
            }

Code Block

return error();
        }
[...]

 This could have been avoided by

...

provideLoginTicketToRemoteRequestorAction -> introduces a new parameter get-lt to signal that a new login ticket shall be issued to the HTTP Referer:

Code Block

package de.businessmart.sso.cas.flow;

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

import org.jasig.cas.web.flow.util.ContextUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.webflow.action.AbstractAction;
import org.springframework.webflow.core.collection.MutableAttributeMap;
import org.springframework.webflow.execution.Event;
import org.springframework.webflow.execution.RequestContext;

import de.businessmart.sso.cas.CasUtility;

/**
 * Opens up the CAS web flow to allow external retrieval of a login ticket.
 * @author konrad.wulf
 *
 */
public class ProvideLoginTicketToRemoteRequestorAction extends AbstractAction
{

	@Override
	protected Event doExecute(RequestContext context) throws Exception
	{
		final HttpServletRequest request = ContextUtils.getHttpServletRequest(context);

		if (request.getParameter("get-lt") != null && request.getParameter("get-lt").equalsIgnoreCase("true")) {
			return result("loginTicketRequested");
		}
		return result("continue");
	}

}

viewRedirectToRequestor actually does the redirects to the referrer:

Code Block

<%@page import="de.businessmart.sso.cas.CasUtility"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<% String separator = "";
String referrer = request.getHeader("Referer");
referrer = CasUtility.resetUrl(referrer);
if (referrer != null && referrer.length() > 0) {
    separator =(referrer.indexOf("?") > -1)? "&" : "?"; %>
<html>
    <head>
        <script>
               var redirectURL = "<%= referrer + separator %>lt=${flowExecutionKey}";
        <spring:hasBindErrors name="credentials">
            redirectURL += '&error_message=' + encodeURIComponent ('<c:forEach var="error" items="${errors.allErrors}"><spring:message code="${error.code}" text="${error.defaultMessage}" /></c:forEach>');
        </spring:hasBindErrors>
            window.location.href = redirectURL;
       </script>
    </head>
    <body></body>
</html><%
}
else
{
    out.print("You better know what to do here.");
} %>

 

And for completeness, here is the CasUtility that removes obsolete parameters from the query string:

Code Block

package de.businessmart.sso.cas;

public class CasUtility {
    /**
     * Removes the previously attached GET parameters "lt" and "error_message" to be able to send new ones.
     * @param casUrl
     * @return
     */
    public static String resetUrl ( String casUrl) {
        String cleanedUrl;
        String[] paramsToBeRemoved = new String[]{"lt", "error_message", "get-lt"};
        cleanedUrl = removeHttpGetParameters(casUrl, paramsToBeRemoved);
        return cleanedUrl;
    }


    /**
     * Removes selected HTTP GET parameters from a given URL
     * @param casUrl
     * @param paramsToBeRemoved
     * @return
     */
    public static String removeHttpGetParameters (String casUrl, String[] paramsToBeRemoved) {
        String cleanedUrl = casUrl;
        if (casUrl != null)
        {
            // check if there is any query string at all
            if (casUrl.indexOf("?") == -1)
            {
                return casUrl;
            } else
            {
                // determine the start and end position of the parameters to be removed
                int startPosition, endPosition;
                boolean containsOneOfTheUnwantedParams = false;
                for (String paramToBeErased : paramsToBeRemoved)
                {
                    startPosition = -1;
                    endPosition = -1;
                    if (cleanedUrl.indexOf("?" + paramToBeErased + "=") > -1)
                    {
                        startPosition = cleanedUrl.indexOf("?"
                                + paramToBeErased + "=") + 1;
                    } else if (cleanedUrl.indexOf("&" + paramToBeErased + "=") > -1)
                    {
                        startPosition = cleanedUrl.indexOf("&"
                                + paramToBeErased + "=") + 1;
                    }
                    if (startPosition > -1)
                    {
                        int temp = cleanedUrl.indexOf("&", startPosition);
                        endPosition = (temp > -1) ? temp + 1 : cleanedUrl
                                .length();
                        // remove that parameter, leaving the rest untouched
                        cleanedUrl = cleanedUrl.substring(0, startPosition)
                                + cleanedUrl.substring(endPosition);
                        containsOneOfTheUnwantedParams = true;
                    }
                }

                // wenn nur noch das Fragezeichen vom query string übrig oder am schluss ein "&", dann auch dieses entfernen
                if (cleanedUrl.endsWith("?") || cleanedUrl.endsWith("&"))
                {
                    cleanedUrl = cleanedUrl.substring(0,
                            cleanedUrl.length() - 1);
                }

                // erst zurückgeben, wenn wir auch überprüft haben, ob nicht ein parameter mehrfach angegeben wurde...
                if (!containsOneOfTheUnwantedParams)
                    return casUrl;
                else
                    cleanedUrl = removeHttpGetParameters(cleanedUrl, paramsToBeRemoved);
            }
        }
        return cleanedUrl;

    }

}

...