CASifying Sakai

CASifying Sakai

Looking for CAS 3 info?

These instructions are for CAS 2. For CAS 3, read the new article

I initially followed the instructions here (http://confluence.sakaiproject.org/display/SAKDEV/CASifying+Sakai) and chose to integrate CAS via a servlet filter. I found the instructions lacked a bit of substance, so had to do a lot of experimenting myself. Below is a quick howto on what I did.

1) Configure sakai-login-tool's web.xml

There are two blocks you need to add to the sakai-login-tool's web.xml file.
Edit: $SAKAI_SRC/login/login-tool/tool/src/webapp/WEB-INF/web.xml

First, the filter and filter-mapping blocks; add them after any others that appear in that file as below:

[...]
    <filter-mapping>
        <filter-name>sakai.request.container</filter-name>
        <servlet-name>sakai.login.container</servlet-name>
        <dispatcher>REQUEST</dispatcher>
    </filter-mapping>

<!-- begin CAS servlet filter -->

    <filter>
        <filter-name>sakai.cas</filter-name>
            <filter-class>edu.yale.its.tp.cas.client.filter.CASFilter</filter-class>
            <init-param>
                <param-name>edu.yale.its.tp.cas.client.filter.loginUrl</param-name>
                <param-value>https://login.une.edu.au/login</param-value>
            </init-param>
            <init-param>
               <param-name>edu.yale.its.tp.cas.client.filter.validateUrl</param-name>
               <param-value>https://login.une.edu.au/serviceValidate</param-value>
           </init-param>
           <init-param>
               <param-name>edu.yale.its.tp.cas.client.filter.serverName</param-name>
               <param-value>sakai.une.edu.au</param-value>
           </init-param>
           <init-param>
               <param-name>edu.yale.its.tp.cas.client.filter.wrapRequest</param-name>
               <param-value>true</param-value>
           </init-param>
    </filter>

    <filter-mapping>
        <filter-name>sakai.cas</filter-name>
        <url-pattern>/container</url-pattern>
    </filter-mapping>

<!-- end CAS servlet filter -->

    <servlet>
        <servlet-name>sakai.login</servlet-name>
        <servlet-class>org.sakaiproject.tool.login.LoginTool</servlet-class>
[...]

Of course, you need to replace the above URLs with the URLs that are relevant to your installation.

Next, add another filter-mapping block to force requests for /container through Sakai's RequestFilter. This filter must be placed close to the top of web.xml, near:

[...]
        <filter-class>org.sakaiproject.util.RequestFilter</filter-class>
    </filter>

<!-- Force request for /container through the request filter -->

    <filter-mapping>
        <filter-name>sakai.request</filter-name>
        <url-pattern>/*</url-pattern>
        <dispatcher>REQUEST</dispatcher>
        <dispatcher>FORWARD</dispatcher>
        <dispatcher>INCLUDE</dispatcher>
    </filter-mapping>

<!-- end filter mapping addition -->

    <filter>
        <filter-name>sakai.request.container</filter-name>
        <filter-class>org.sakaiproject.util.RequestFilter</filter-class>
[...]

The order is important

Please note that the order of the above blocks in the web.xml is important. They form a chain and each one passes off to the next one in the chain. See here for more information: http://java.sun.com/products/servlet/Filters.html

2) Modify the login-tool's project.xml (2.4.x) or pom.xml(2.5.x +) to include the casclient.jar automatically

You need a CAS filter to be deployed to TOMCAT/sakai-login/tool/WEB-INF/lib. You can either manually put it there after the war has been expanded (but it will be overwritten if you redeploy the war), or just add it as a dependency and get it included in the war itself!

Sakai 2.4.x Edit: $SAKAI_SRC/login/login-tool/tool/project.xml
And add:

<dependency>
   <groupId>cas</groupId>
   <artifactId>casclient</artifactId>
   <version>2.1.1</version>
   <properties>
	<war.bundle>true</war.bundle>
   </properties>
</dependency>

Sakai 2.5.x Edit: $SAKAI_SRC/login/login-tool/tool/pom.xml
And add:

<dependency>
   <groupId>cas</groupId>
   <artifactId>casclient</artifactId>
   <version>2.1.1</version>
</dependency>

It will be fetched and installed by Maven, then deployed automatically. You might need http://repo1.maven.org/maven/ in your maven.repo.remote in build.properties so it can find the casclient jar.

3) Modify sakai.properties

For our requirements, we need everyone to login and logout via CAS. To do this, we need to remove the username/password boxes at the top, enable the container to handle the login via CAS, and force logouts to be handled by CAS also.

# Remove the username/password boxes at the top by setting this to false
top.login = false

# Let the container handle logins - ie to use single sign-on.
container.login = true

# Force logouts via CAS also - your requirements will be different for this.
# The URL below allows us to logout via CAS and then be redirected back to our Sakai server.
loggedOutUrl=https://login.une.edu.au/logout?service=http://sakai.une.edu.au/
3) Rebuild the login project, restart Sakai and test.

Clicking on the "Login" link now redirects me for authentication, and then logs me into Sakai.

Accessing the CAS authenticated username:

To access the authenticated username and other information from any JSP/Servlets I use:

// get the authenticated username (2 methods of doing this)
 session.getAttribute(CASFilter.CAS_FILTER_USER);
 session.getAttribute("edu.yale.its.tp.cas.client.filter.user");

//get additional information about the authentication receipt  (2 methods of doing this)
 session.getAttribute(CASFilter.CAS_FILTER_RECEIPT);
 session.getAttribute("edu.yale.its.tp.cas.client.filter.receipt");

Enabling local login as well as a CAS login

It is possible to have another login link that authenticates against Sakai itself, and we use this for users that are not in our LDAP system, but need to be able to login to Sakai (for instance, some units offered through UNE are available to students at other Universities, but they are not in our main Student Information System, so they can be stored just in Sakai and login to Sakai only).
To do that, modify your sakai.properties to add the xlogin section:

# Second login link (bypasses container auth)
xlogin.enabled=true
xlogin.text=Guest Login
login.use.xlogin.to.relogin=false

I have also written a CAS Perl module, which uses both CGI::Session to setup a session, and the returned CAS ticket for validation. Once you get the ticket, if you don't have a session already, one is established and that is used for any further authenticated requests. When the session times out, a new ticket is issued and this prompts a new session.

CAS.pm()
package UNE::CAS;


# simple code to hook into a CAS server via Perl
# -----------------------------------------------
#
# Steve Swinsburg, 15th April 2007.
#
# This code is based on an idea from Indiana University but modified to suit
# the University of New England's CAS requirements. I wrote this because all
# other Perl modules out there were required too many dependencies and were
# difficult to get going. Hopefully this should do the trick.
#
# The function accepts 4 parameters
# - $cas_login which is the URL of the CAS login
# - $cas_validate which is the URL of the CAS ticket validator
# - $cas_service which is the URL of the app that is being CASified
# - $cas_ticket which is the ticket string if its already set
#
# If successfully authenticated it will return the authenticated username.
# If authentication fails, it returns 'AUTHENTICATION_FAILED'.
# If it needs to authenticate, then it returns 'AUTHENTICATION_REQUIRED'.


use LWP::UserAgent;

sub getAuthenticatedUsername {

	#sent to this function by the app that called it
	local($cas_login, $cas_validate, $cas_service, $cas_ticket) = @_;

	# User Agent object to handle HTTP requests
	my $ua = new LWP::UserAgent;

	# Check if we have a CAS ticket and if its of a valid form
	if (defined($cas_ticket) && $cas_ticket =~ m/^ST-\d+-[A-Za-z0-9]+$/){

		# Validate the ticket
		my $req = new HTTP::Request('GET', "$cas_validate?service=$cas_service&ticket=$cas_ticket");
		my $res = $ua->request($req);
		my $validate_reply = $res->content;

		# The validation reply is a fragment of XML like this:
		# FAILED
		#<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
		#	<cas:authenticationFailure code='INVALID_TICKET'>
		#		ticket 'ST-24355-7s6GVyTgNM8Pt7PnoSgc' not recognized
		#	</cas:authenticationFailure>
		#</cas:serviceResponse>
		# SUCCESS
		#<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
		#	<cas:authenticationSuccess>
		#		<cas:user>sswinsb2</cas:user>
		#	</cas:authenticationSuccess>
		#</cas:serviceResponse>

		# Therefore we can check for a matching string to see if it was successful and get the username from the XML fragment
		if ($validate_reply =~ /(<cas:user>)\w+(<\/cas:user>)/) {
			$cas_username = $&;
			$cas_username =~ s/(<\/?\w+:\w+>)//g;
			return $cas_username;
		} else {
			return 'AUTHENTICATION_FAILED';
		}
	} else {
		return 'AUTHENTICATION_REQUIRED';
	}

}

return 1;

There are probably issues with this code but it seems to be running fine! Like I said above, you need to make sure your CAS server is returning usernames wrapped in XML the same way as above, or modify the code to suit. Feedback appreciated.