Monday, March 26, 2012

Single-Sign-On using CAS, Spring, and LDAP

In one of the previous posts, which turned out to be quite popular, I wrote about doing custom authentication with Spring. This time I am going to tackle the problem in a more proper and standardized way. This will be based on CAS, Spring, and LDAP.

In order to achieve what I want I'll set up a CAS server, and modify my Spring application to use CAS for authentication. Once this step passed, I'll go ahead and bind the CAS server to a backing LDAP which provides user information, and finally populate group memberships in LDAP as user roles to be used for authorisation. I'll frequently refer to CAS and Spring Security documentations along the way. I'll also refer to this useful blog post for LDAP integration.

There are so many good resources on how to do this, at least up to the point where you want to talk to LDAP. Hence I'll cut the story short and provide my final and complete configuration which contains everything together. You need to have and LDAP server installed before you can try this out for yourself. I'd highly recommend Apache DS as it is very easy to set up. I spent hours on configuring OpenLDAP/SLAP with no luck.

The complete source code for this sample can be found here, or checked out on your own machine using this command:
svn checkout http://tinywebgears-samples.googlecode.com/svn/trunk/sso-cas-spring sso-cas-spring


The client code is pretty straightforward, just any other Spring client as described here. CAS server is only a web application and if you follow the WAR Overlay method, you only need to modify a few places. I had to modify deployerConfigContext.xml, securityContext.xml, and cas.properties, then added a new jsp page (casServiceValidationSuccess.jsp) and a Java class (LdapPersonAttributeAndRoleDao.java).

For your convenience and easy reading I'm putting contents of these files here at the end.

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

  http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-2.5.xsd">

<bean id="authenticationManager"
     class="org.jasig.cas.authentication.AuthenticationManagerImpl">
   <property name="credentialsToPrincipalResolvers">
       <list>
           <bean class="org.jasig.cas.authentication.principal.CredentialsToLDAPAttributePrincipalResolver">
               <!-- The Principal resolver form the credentials -->
               <property name="credentialsToPrincipalResolver">
                   <bean class="org.jasig.cas.authentication.principal.UsernamePasswordCredentialsToPrincipalResolver"/>
               </property>
               <!-- The query made to find the Principal ID. "%u" will be replaced by the resolved Principal -->
               <property name="filter" value="(uid=%u)"/>
               <!-- The attribute used to define the new Principal ID -->
               <property name="principalAttributeName" value="cn"/>
               <property name="searchBase" value="ou=users,ou=system"/>
               <property name="contextSource" ref="contextSource"/>
               <property name="attributeRepository">
                   <ref bean="attributeRepository"/>
               </property>
           </bean>
           <bean
                   class="org.jasig.cas.authentication.principal.HttpBasedServiceCredentialsToPrincipalResolver"/>
       </list>
   </property>

   <property name="authenticationHandlers">
       <list>
           <bean class="org.jasig.cas.authentication.handler.support.HttpBasedServiceCredentialsAuthenticationHandler"
                 p:httpClient-ref="httpClient"/>
           <bean class="org.jasig.cas.adaptors.ldap.BindLdapAuthenticationHandler">
               <property name="filter" value="uid=%u"/>
               <property name="searchBase" value="ou=users,ou=system"/>
               <property name="contextSource" ref="contextSource"/>
           </bean>
       </list>
   </property>
</bean>


<bean id="contextSource" class="org.springframework.ldap.core.support.LdapContextSource">
   <property name="pooled" value="false"/>
   <property name="urls">
       <list>
           <value>ldap://localhost:10389</value>
       </list>
   </property>
   <property name="userDn" value="uid=admin,ou=system"/>
   <property name="password" value="secret"/>
   <property name="baseEnvironmentProperties">
       <map>
           <entry>
               <key>
                   <value>java.naming.security.authentication</value>
               </key>
               <value>simple</value>
           </entry>
       </map>
   </property>
</bean>

<bean id="userDetailsService"
     class="org.springframework.security.cas.userdetails.GrantedAuthorityFromAssertionAttributesUserDetailsService">
   <constructor-arg>
       <list>
           <value>authorities</value>
       </list>
   </constructor-arg>
</bean>

<bean id="attributeRepository" class="org.jasig.services.persondir.support.ldap.LdapPersonAttributeAndRoleDao">
   <property name="contextSource" ref="contextSource"/>
   <property name="baseDN" value="ou=users,ou=system"/>
   <property name="requireAllQueryAttributes" value="true"/>
   <!-- Attribute mapping between principal (key) and LDAP (value) names used to perform the LDAP search.
By default, multiple search criteria are ANDed together.  Set the queryType property to change to OR. -->
   <property name="queryAttributeMapping">
       <map>
           <entry key="username" value="uid"/>
       </map>
   </property>
   <property name="resultAttributeMapping">
       <map>
           <!-- Mapping beetween LDAP entry attributes (key) and Principal's (value) -->
           <entry key="mail" value="email"/>
           <entry key="authorities" value="authorities"/>
       </map>
   </property>
   <property name="ldapAuthoritiesPopulator" ref="ldapAuthoritiesPopulator"/>
</bean>

<bean id="ldapAuthoritiesPopulator"
     class="org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator">
   <constructor-arg ref="contextSource"/>
   <constructor-arg value="ou=groups,ou=system"/>
   <property name="groupRoleAttribute" value="cn"/>
   <property name="groupSearchFilter" value="(uniqueMember={0})"/>
</bean>


<bean id="serviceRegistryDao" class="org.jasig.cas.services.JpaServiceRegistryDaoImpl"
     p:entityManagerFactory-ref="entityManagerFactory"/>
<!-- This is the EntityManagerFactory configuration for Hibernate -->
<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
   <property name="dataSource" ref="dataSource"/>
   <property name="jpaVendorAdapter">
       <bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
           <property name="generateDdl" value="true"/>
           <property name="showSql" value="false"/>
       </bean>
   </property>
   <property name="jpaProperties">
       <props>
           <prop key="hibernate.dialect">org.hibernate.dialect.MySQL5Dialect</prop>
           <prop key="hibernate.hbm2ddl.auto">update</prop>
       </props>
   </property>
</bean>
<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
   <property name="entityManagerFactory" ref="entityManagerFactory"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
   <property name="driverClassName" value="com.mysql.jdbc.Driver"/>
   <property name="url" value="${database.url}"/>
   <property name="username" value="${database.username}"/>
   <property name="password" value="${database.password}"/>
   <property name="validationQuery" value="select 1"/>
   <property name="testOnBorrow" value="false"/>
   <property name="testWhileIdle" value="true"/>
   <property name="defaultAutoCommit" value="false"/>
</bean>

<bean id="auditTrailManager" class="com.github.inspektr.audit.support.Slf4jLoggingAuditTrailManager"/>

</beans>


securityContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:p="http://www.springframework.org/schema/p"
  xmlns:sec="http://www.springframework.org/schema/security"
  xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
  http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.0.xsd">
<description>
   This is the configuration file for the Spring Security configuration used with the services management tool. You
   shouldn't
   have to modify anything in this file directly. The configuration options should all be in the cas.properties
   file.
</description>

<sec:http entry-point-ref="casProcessingFilterEntryPoint" auto-config="true">
   <sec:intercept-url pattern="/services/loggedout.html" filters="none"/>
   <sec:intercept-url pattern="/**" access="${cas.securityContext.serviceProperties.adminRoles}"/>
   <sec:logout logout-url="/services/logout.html" logout-success-url="/services/loggedOut.html"/>
   <sec:custom-filter ref="casProcessingFilter" after="CAS_FILTER"/>
</sec:http>

<sec:authentication-manager alias="casAuthenticationManager">
   <sec:authentication-provider ref="casAuthenticationProvider"/>
</sec:authentication-manager>

<bean id="serviceProperties" class="org.springframework.security.cas.ServiceProperties"
     p:service="${cas.securityContext.serviceProperties.service}"
     p:sendRenew="false"/>

<bean id="casProcessingFilter" class="org.springframework.security.cas.web.CasAuthenticationFilter"
     p:authenticationManager-ref="casAuthenticationManager"
     p:filterProcessesUrl="/services/j_acegi_cas_security_check">
   <property name="authenticationSuccessHandler">
       <bean class="org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler"
             p:alwaysUseDefaultTargetUrl="true"
             p:defaultTargetUrl="/services/manage.html"/>
   </property>
   <property name="authenticationFailureHandler">
       <bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
           <constructor-arg index="0" value="/authorizationFailure.html"/>
       </bean>
   </property>
</bean>

<bean id="casProcessingFilterEntryPoint" class="org.springframework.security.cas.web.CasAuthenticationEntryPoint"
     p:loginUrl="${cas.securityContext.casProcessingFilterEntryPoint.loginUrl}"
     p:serviceProperties-ref="serviceProperties"/>

<bean id="casAuthenticationProvider"
     class="org.springframework.security.cas.authentication.CasAuthenticationProvider"
     p:key="my_password_for_this_auth_provider_only"
     p:serviceProperties-ref="serviceProperties"
     p:authenticationUserDetailsService-ref="userDetailsService">
   <property name="ticketValidator">
       <bean class="org.jasig.cas.client.validation.Saml11TicketValidator">
           <constructor-arg index="0" value="${cas.securityContext.ticketValidator.casServerUrlPrefix}"/>
       </bean>
   </property>
</bean>

</beans>


cas.properties
server.prefix=https://localhost:8443/cas

cas.securityContext.serviceProperties.service=${server.prefix}/services/j_acegi_cas_security_check
# Names of roles allowed to access the CAS service manager
cas.securityContext.serviceProperties.adminRoles=ROLE_ADMINISTRATORS
cas.securityContext.casProcessingFilterEntryPoint.loginUrl=${server.prefix}/login
cas.securityContext.ticketValidator.casServerUrlPrefix=${server.prefix}


cas.themeResolver.defaultThemeName=cas-theme-default
cas.viewResolver.basename=default_views

host.name=localhost

#database.hibernate.dialect=org.hibernate.dialect.OracleDialect
database.hibernate.dialect=org.hibernate.dialect.MySQLDialect
#database.hibernate.dialect=org.hibernate.dialect.HSQLDialect
database.url=jdbc:mysql://localhost/cas
database.username=cas
database.password=cas


casServiceValidationSuccess.jsp
<%@ page session="false" %><%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %><%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %><cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>${fn:escapeXml(assertion.chainedAuthentications[fn:length(assertion.chainedAuthentications)-1].principal.id)}</cas:user>
<c:if test="${not empty pgtIou}">
<cas:proxyGrantingTicket>${pgtIou}</cas:proxyGrantingTicket>
</c:if>
<c:if test="${fn:length(assertion.chainedAuthentications) > 1}">
<cas:proxies>
<c:forEach var="proxy" items="${assertion.chainedAuthentications}" varStatus="loopStatus" begin="0" end="${fn:length(assertion.chainedAuthentications)-2}" step="1">
<cas:proxy>${fn:escapeXml(proxy.principal.id)}</cas:proxy>
</c:forEach>
</cas:proxies>
</c:if>
<!-- Begin Ldap Attributes -->
<c:if test="${fn:length(assertion.chainedAuthentications) > 0}">
<cas:attributes>
<c:forEach var="auth" items="${assertion.chainedAuthentications}">
<c:forEach var="attr" items="${auth.principal.attributes}" >
<cas:${fn:escapeXml(attr.key)}>${fn:escapeXml(attr.value)}</cas:${fn:escapeXml(attr.key)}>
</c:forEach>
</c:forEach>
</cas:attributes>
</c:if>
<!-- End Ldap Attributes -->
</cas:authenticationSuccess>
</cas:serviceResponse>


LdapPersonAttributeAndRoleDao.java
package org.jasig.services.persondir.support.ldap;

import org.jasig.services.persondir.IPersonAttributes;
import org.jasig.services.persondir.support.CaseInsensitiveAttributeNamedPersonImpl;
import org.springframework.ldap.core.DirContextAdapter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;

import java.util.*;

public class LdapPersonAttributeAndRoleDao extends LdapPersonAttributeDao
{
private DefaultLdapAuthoritiesPopulator ldapAuthoritiesPopulator;

@Override
protected List<IPersonAttributes> getPeopleForQuery(LogicalFilterWrapper queryBuilder, String queryUserName)
{
   //the list of users with the username as fetched from the base class (no role info here)
   List<IPersonAttributes> attribs = super.getPeopleForQuery(queryBuilder, queryUserName);

   //the list of users that we want to return
   final List<IPersonAttributes> peopleWithRoles = new ArrayList<IPersonAttributes>(attribs.size());

   //Fetch the Authorities from Ldap
   Collection<GrantedAuthority> authorities = null;
   try
   {
       String userDn = "cn=" + queryUserName + "," + getBaseDN();
       //Utilize the Spring Security Ldap functionality to obtain granted authorities
       authorities = ldapAuthoritiesPopulator.getGrantedAuthorities(new DirContextAdapter(userDn), queryUserName);
       logger.info("Authorities: " + authorities);
   }
   catch (Exception nnfe)
   {
       //we just won't add authorities if there was an error.
       logger.error("error looking up authorities", nnfe);
   }

   //add authorities in the format required, if there are any found
   List<Object> authoritiesList;
   if (null != authorities)
   {
       //transform the GrantedAuthority list into a List of Strings
       authoritiesList = new ArrayList<Object>();
       for (GrantedAuthority auth : authorities)
       {
           authoritiesList.add(auth);
       }

       for (IPersonAttributes person : attribs)
       {
           // the new list of attributes
           Map<String, List<Object>> attrs = new HashMap<String, List<Object>>();
           // add the old attributes
           attrs.putAll(person.getAttributes());
           // add the authorities
           attrs.put("authorities", authoritiesList);
           // add the person to the return list.
           peopleWithRoles.add(new CaseInsensitiveAttributeNamedPersonImpl(this.getConfiguredUserNameAttribute(), attrs));
       }
   }
   else
   {
       peopleWithRoles.addAll(attribs);
   }

   return peopleWithRoles;
}

public DefaultLdapAuthoritiesPopulator getLdapAuthoritiesPopulator()
{
   return ldapAuthoritiesPopulator;
}

public void setLdapAuthoritiesPopulator(DefaultLdapAuthoritiesPopulator ldapAuthoritiesPopulator)
{
   this.ldapAuthoritiesPopulator = ldapAuthoritiesPopulator;
}
}