Thursday, December 14, 2006

Acegi/Spring Security with X509 and LDAP Authentication

We have chosen Acegi security for our security interface for our web applications. We have decided to use X509 certificates to obtain user’s credential and roles. If the certificate is not available or expired, we then show the login page having the user enter the user name and password. To accomplish this we used a special XML configuration and had to create 3 new classes.

EhCacheX509LdapUserCache.java (source)
This class is used to search the cache for the user. We are using two different authenticator providers and because each provider implements the cache the differently, this class is used to provide a bridge between the two concepts by delegating methods to the underlying store.

SecurityContextHolderAwareRequestWrapperWrapper.java (source)
This class wraps SecurityContextHolderAwareRequestWrapper because the SecurityContextHolderAwareRequestFilter api requires both request and port resolver, however, Acegi’s SecurityContextHolderAwareRequestWrapper only supports request. Hopefully Acegi will fix this problem in the future, removing the need for this class.

X509LdapAuthoritiesPopulator.java (source)
We are using 2 authorities populators so our class implements the X509 interface and delegates to the LDAP authority populator. We expect to obtain the userid from the certificate or login page while we obtain the roles from LDAP. One note of interest is that we use Perl to simplify the use of regular expressions because of the need to search the CN string for the user name.

How they all tie together
We have enabled these all by creating a Spring configuration that utilizes them. In addition, we needed a login controller to redirect the user to their desired URI upon successful authentication (only necessary for form-based login). The last step is to make sure that your web.xml uses the correct servlet mappings, the following is how we organized ours:

<servlet-mapping>
<servlet-name>Phoenix</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>Phoenix</servlet-name>
<url-pattern>/login_error</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>Phoenix</servlet-name>
<url-pattern>/app/*</url-pattern>
</servlet-mapping>


Source


EhCacheX509LdapUserCache.java (top)
package com.checkernet.security;

import java.security.cert.X509Certificate;
import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheException;
import net.sf.ehcache.Element;
import org.acegisecurity.providers.dao.UserCache;
import org.acegisecurity.providers.x509.X509UserCache;
import org.acegisecurity.userdetails.UserDetails;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.MatchResult;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternMatcher;
import org.apache.oro.text.regex.Perl5Compiler;
import org.apache.oro.text.regex.Perl5Matcher;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.util.Assert;

/**
 * Notes:<br/>
 * Implements {@link org.acegisecurity.providers.x509.X509UserCache}
 * and {@link org.acegisecurity.providers.dao.UserCache} <br />
 * Used to provide a bridge between the two concepts by delegating methods to the underlying store
 *
 @author JoeS@checkernet.com
 @version 1.0.0
 */
@SuppressWarnings({"OverloadedMethodsWithSameNumberOfParameters"})
public class EhCacheX509LdapUserCache implements UserCache, X509UserCache, InitializingBean {

    private final Log logger = LogFactory.getLog(EhCacheX509LdapUserCache.class);

    private Cache cache;

    private Pattern subjectDNPattern;

    private String subjectDNRegex = "CN=(.*?),";

    public void setCache(final Cache cache) {
        this.cache = cache;
    }

    /**
     * Obtains a {@link UserDetails} from the cache.
     *
     @param username the {@link org.acegisecurity.userdetails.User#getUsername()} used to place the user in
     *                 the cache
     @return the populated <code>UserDetails</code> or <code>null</code> if
     *         the user could not be found or if the cache entry has expired
     */
    public UserDetails getUserFromCache(final String username) {
        final Element element;

        try {
            element = cache.get(username);
        catch (CacheException cacheException) {
            //noinspection ThrowInsideCatchBlockWhichIgnoresCaughtException
            throw new DataRetrievalFailureException("Cache failure: " + cacheException.getMessage());
        }

        if (logger.isDebugEnabled()) {
            logger.debug("Cache hit for userName: " + username);
        }

        if (element == null) {
            //noinspection ReturnOfNull
            return null;
        else {
            return (UserDetailselement.getValue();
        }
    }

    /**
     * Obtains a {@link UserDetails} from the cache.
     *
     @param userCert the {@link X509Certificate} used to place the user in
     *                 the cache
     @return the populated <code>UserDetails</code> or <code>null</code> if
     *         the user could not be found or if the cache entry has expired
     */
    public UserDetails getUserFromCache(final X509Certificate userCert) {
        if (userCert != null && userCert.getSubjectDN() != null) {
            return getUserFromCache(extractUsernameFromCert(userCert));
        else if (logger.isDebugEnabled()) {
            logger.debug("Certificate or user is null");
        }

        //noinspection ReturnOfNull
        return null;
    }

    /**
     * Places a {@link UserDetails} in the cache. The <code>username</code> is
     * the key used to subsequently retrieve the <code>UserDetails</code>.
     *
     @param user the fully populated <code>UserDetails</code> to place in the
     *             cache
     */
    public void putUserInCache(final UserDetails user) {
        Element element = new Element(user.getUsername(), user);

        if (logger.isDebugEnabled()) {
            logger.debug("Cache put: " + user.getUsername());
        }

        cache.put(element);
    }

    /**
     * delegates to #putUserInCache(final UserDetails user)
     *
     @param userCert user's X509Certificate
     @param user     the fully populated <code>UserDetails</code> to place in the
     *                 cache
     */
    public void putUserInCache(final X509Certificate userCert, final UserDetails user) {
        putUserInCache(user);
    }

    /**
     * Removes the specified user from the cache. The <code>username</code> is
     * the key used to remove the user. If the user is not found, the method
     * should simply return (not thrown an exception).
     <p></p>
     <p>Some cache implementations may not support eviction from the cache,  in
     * which case they should provide appropriate behaviour to alter the user
     * in either its documentation, via an exception, or through a log
     * message.</p>
     *
     @param username to be evicted from the cache
     */
    public void removeUserFromCache(final String username) {
        if (logger.isDebugEnabled()) {
            logger.debug("Cache remove: " + username);
        }

        cache.remove(username);
    }

    /**
     * delegates to #removeUserFromCache(final String username)
     *
     @param userCert user's X509Certificate
     */
    public void removeUserFromCache(final X509Certificate userCert) {
        removeUserFromCache(extractUsernameFromCert(userCert));
    }

    /**
     * {@inheritDoc}
     */
    @SuppressWarnings({"ProhibitedExceptionDeclared"})
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(cache, "cache is mandatory");

        final Perl5Compiler compiler = new Perl5Compiler();

        //noinspection UnusedCatchParameter
        try {
            subjectDNPattern = compiler.compile(subjectDNRegex,
                                                Perl5Compiler.READ_ONLY_MASK
                                                | Perl5Compiler.CASE_INSENSITIVE_MASK);
        catch (MalformedPatternException mpe) {
            //noinspection ThrowInsideCatchBlockWhichIgnoresCaughtException
            throw new IllegalArgumentException("Malformed regular expression: "
                                               + subjectDNRegex);
        }
    }

    /**
     * extracts a username from a certificate's subjectDN
     *
     @param userCert X509Certificate
     @return String username
     */
    private String extractUsernameFromCert(final X509Certificate userCert) {
        final PatternMatcher matcher = new Perl5Matcher();

        if (userCert == null || userCert.getSubjectDN() == null
            || !matcher.contains(userCert.getSubjectDN().toString(), subjectDNPattern)) {
            //noinspection ReturnOfNull
            return null;
        }

        final MatchResult match = matcher.getMatch();

        if (match.groups() != 2) { // 2 = 1 + the entire match
            throw new IllegalArgumentException(
                    "Regular expression must contain a single group ");
        }

        return match.group(1);
    }
}


SecurityContextHolderAwareRequestWrapperWrapper.java (top)
package com.checkernet.security;

import javax.servlet.http.HttpServletRequest;
import org.acegisecurity.util.PortResolver;
import org.acegisecurity.wrapper.SecurityContextHolderAwareRequestWrapper;

/**
 * Notes:<br />
 * This class acts as a wrapper for org.acegisecurity.wrapper.SecurityContextHolderAwareRequestWrapper because it does
 * not conform to the SecurityContextHolderAwareRequestFilter api. This class may be removed when Acegi fixes this bug.
 *
 @author JoeS@checkernet.com
 @version 1.0.0
 */
@SuppressWarnings({"ClassNamePrefixedWithPackageName""ClassNameSameAsAncestorName"})
public class SecurityContextHolderAwareRequestWrapperWrapper
        extends SecurityContextHolderAwareRequestWrapper {

    /**
     * conforming to SecurityContextHolderAwareRequestFilter api
     *
     @param request      HttpServletRequest
     @param portResolver PortResolver (not currently used)
     */
    @SuppressWarnings({"UNUSED_SYMBOL"})
    public SecurityContextHolderAwareRequestWrapperWrapper(final HttpServletRequest request,
                                                           final PortResolver portResolver) {
        super(request);
    }
}


X509LdapAuthoritiesPopulator.java (top)
package com.checkernet.security;

import java.security.cert.X509Certificate;
import org.acegisecurity.BadCredentialsException;
import org.acegisecurity.ldap.LdapUserSearch;
import org.acegisecurity.providers.ldap.LdapAuthoritiesPopulator;
import org.acegisecurity.providers.x509.X509AuthoritiesPopulator;
import org.acegisecurity.userdetails.User;
import org.acegisecurity.userdetails.UserDetails;
import org.acegisecurity.userdetails.ldap.LdapUserDetails;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.MatchResult;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternMatcher;
import org.apache.oro.text.regex.Perl5Compiler;
import org.apache.oro.text.regex.Perl5Matcher;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;

/**
 * Notes:<br/>
 * Implements the {@link X509AuthoritiesPopulator} while providing roles lookup from LDAP
 *
 @author JoeS@checkernet.com
 @version 1.0.0
 */
@SuppressWarnings({"ClassNamingConvention"})
public class X509LdapAuthoritiesPopulator implements X509AuthoritiesPopulator, InitializingBean {

    private LdapUserSearch userSearch;

    private LdapAuthoritiesPopulator authoritiesPopulator;

    private Pattern subjectDNPattern;

    private String subjectDNRegex = "CN=(.*?),";

    public void setAuthoritiesPopulator(final LdapAuthoritiesPopulator authoritiesPopulator) {
        this.authoritiesPopulator = authoritiesPopulator;
    }

    public void setUserSearch(final LdapUserSearch userSearch) {
        this.userSearch = userSearch;
    }

    /**
     * {@inheritDoc}
     */
    @SuppressWarnings({"ProhibitedExceptionDeclared"})
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(userSearch, "A userSearch must be set");
        Assert.notNull(authoritiesPopulator, "An authoritiesPopulator must be set");

        final Perl5Compiler compiler = new Perl5Compiler();

        //noinspection UnusedCatchParameter
        try {
            subjectDNPattern = compiler.compile(subjectDNRegex,
                                                Perl5Compiler.READ_ONLY_MASK
                                                | Perl5Compiler.CASE_INSENSITIVE_MASK);
        catch (MalformedPatternException mpe) {
            //noinspection ThrowInsideCatchBlockWhichIgnoresCaughtException
            throw new IllegalArgumentException("Malformed regular expression: "
                                               + subjectDNRegex);
        }
    }

    /**
     * throws AuthenticationException
     * {@inheritDoc}
     */
    public UserDetails getUserDetails(final X509Certificate userCertificate) {
        final String subjectDN = userCertificate.getSubjectDN().getName();
        final PatternMatcher matcher = new Perl5Matcher();

        if (!matcher.contains(subjectDN, subjectDNPattern)) {
            throw new BadCredentialsException("No matching pattern was found in subjectDN: {0}");
            /*throw new BadCredentialsException(messages.getMessage(
                    "DaoX509AuthoritiesPopulator.noMatching",
                    new Object[] {subjectDN},
                    "No matching pattern was found in subjectDN: {0}"));*/
        }

        final MatchResult match = matcher.getMatch();

        if (match.groups() != 2) { // 2 = 1 + the entire match
            throw new IllegalArgumentException(
                    "Regular expression must contain a single group ");
        }

        final String userName = match.group(1);
        LdapUserDetails userDetails = userSearch.searchForUser(userName);
        return new User(userName, "[PROTECTED]", true, true, true, true,
                        authoritiesPopulator.getGrantedAuthorities(userDetails));
    }

}


LoginController.java (top)
package com.checkernet.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.acegisecurity.Authentication;
import org.acegisecurity.context.SecurityContextHolder;
import org.acegisecurity.providers.anonymous.AnonymousAuthenticationToken;
import org.acegisecurity.ui.AbstractProcessingFilter;
import org.acegisecurity.ui.savedrequest.SavedRequest;
import org.acegisecurity.ui.webapp.AuthenticationProcessingFilter;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.RedirectView;

/**
 * Notes:<br />
 *
 @author JoeS@checkernet.com
 @version 1.0.0
 */
public class LoginController extends BaseController implements InitializingBean {

    /**
     * user-defined loginPage view name
     */
    protected String loginPage;

    public void setLoginPage(final String loginPage) {
        this.loginPage = loginPage;
    }

    /**
     * Login Page, redirects to saved request on authentication, otherwise goes to loginPage
     * {@inheritDoc}
     */
    @SuppressWarnings({"ProhibitedExceptionDeclared"})
    protected ModelAndView handleRequestInternal(final HttpServletRequest request, final HttpServletResponse response)
            throws Exception {
        if (logger.isDebugEnabled()) {
            logger.debug("login attempt: "
                         + request.getParameter(AuthenticationProcessingFilter.ACEGI_SECURITY_FORM_USERNAME_KEY));
        }

        final Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
            logger.debug("redirecting to first request");
            final Object savedRequest = request.getSession()
                    .getAttribute(AbstractProcessingFilter.ACEGI_SAVED_REQUEST_KEY);
            if (savedRequest instanceof SavedRequest) {
                return new ModelAndView(new RedirectView(((SavedRequestsavedRequest).getFullRequestUrl()));
            else {
                logger.warn(AbstractProcessingFilter.ACEGI_SAVED_REQUEST_KEY
                            " found in session not of correct type returning to login page");
            }
        }
        return new ModelAndView(loginPage);
    }

    /**
     * {@inheritDoc}
     */
    @SuppressWarnings({"ProhibitedExceptionDeclared"})
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(loginPage, "loginPage is required");
    }
}


x509Ldap-servlet.xml (top)
<?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:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx.xsd">

<!-- This file describes standard concrete beans for security use,
as well as provides abstract beans for bean defaults -->

<!-- Automatically receives AuthenticationEvent messages -->
<bean id="loggerListener" class="org.acegisecurity.event.authentication.LoggerListener" />

<!--ldap configuration-->
<bean id="initialDirContextFactory" class="org.acegisecurity.ldap.DefaultInitialDirContextFactory">
<constructor-arg value="ldap://checkernet.com:389" />
<property name="managerDn">
<value>cn=ReadOnlyUser,ou=webappgroup,dc=checkernet,dc=com</value>
</property>
<property name="managerPassword">
<value>bogusPassword</value>
</property>
<property name="extraEnvVars">
<map>
<entry key="java.naming.referral" value="follow" />
</map>
</property>
</bean>

<bean id="checkerboardLdapProvider" class="org.acegisecurity.providers.ldap.LdapAuthenticationProvider">
<constructor-arg>
<bean class="org.acegisecurity.providers.ldap.authenticator.BindAuthenticator">
<constructor-arg>
<ref local="initialDirContextFactory" />
</constructor-arg>
<property name="userSearch">
<bean class="org.acegisecurity.ldap.search.FilterBasedLdapUserSearch">
<constructor-arg>
<value>DC=checkernet,DC=com</value>
</constructor-arg>
<constructor-arg>
<value>(sAMAccountName={0})</value>
</constructor-arg>
<constructor-arg>
<ref local="initialDirContextFactory" />
</constructor-arg>
<property name="searchSubtree" value="true" />
</bean>
</property>
</bean>
</constructor-arg>
<constructor-arg ref="ldapAuthoritiesPopulator" />
<!--you supply this part:-->
<!--<property name="userCache" ref="userCache" />-->
</bean>

<bean id="ldapAuthoritiesPopulator"
class="org.acegisecurity.providers.ldap.populator.DefaultLdapAuthoritiesPopulator">
<constructor-arg>
<ref local="initialDirContextFactory" />
</constructor-arg>
<constructor-arg>
<value>dc=checkernet,dc=com</value>
</constructor-arg>
<property name="groupSearchFilter">
<value>(member={0})</value>
</property>
<property name="groupRoleAttribute">
<value>CN</value>
</property>
<property name="searchSubtree" value="true" />
</bean>

<!--this bean specifies the special case of binding an x509 cert to an Ldap entry
it requires a userCache to exist-->
<bean id="x509LdapAuthoritiesPopulator" class="com.checkernet.security.X509LdapAuthoritiesPopulator">
<property name="userSearch">
<bean class="org.acegisecurity.ldap.search.FilterBasedLdapUserSearch">
<constructor-arg>
<value>DC=checkernet,DC=com</value>
</constructor-arg>
<constructor-arg>
<value>(cn={0})</value>
</constructor-arg>
<constructor-arg>
<ref local="initialDirContextFactory" />
</constructor-arg>
<property name="searchSubtree" value="true" />
</bean>
</property>
<property name="authoritiesPopulator">
<ref local="ldapAuthoritiesPopulator" />
</property>
</bean>

<!-- An access decision voter that reads ROLE_* configuration settings -->
<bean id="roleVoter" class="org.acegisecurity.vote.RoleVoter" />

<!-- An access decision manager used by the business objects -->
<bean id="accessDecisionManager" class="org.acegisecurity.vote.AffirmativeBased">
<property name="allowIfAllAbstainDecisions" value="false" />
<property name="decisionVoters">
<list>
<ref local="roleVoter" />
</list>
</property>
</bean>

<!-- ================= METHOD INVOCATION AUTHORIZATION ==================== -->

<!--
NOTE:
users need to already be authenticated before invoking this security check
-->
<bean id="securityAttributes" class="org.acegisecurity.annotation.SecurityAnnotationAttributes" />

<!--AOP security config-->
<bean id="securityInterceptor" class="org.acegisecurity.intercept.method.aopalliance.MethodSecurityInterceptor">
<property name="validateConfigAttributes" value="true" />
<property name="authenticationManager" ref="authenticationManager" />
<property name="accessDecisionManager" ref="accessDecisionManager" />
<property name="objectDefinitionSource">
<bean class="org.acegisecurity.intercept.method.MethodDefinitionAttributes">
<property name="attributes" ref="securityAttributes" />
</bean>
</property>
</bean>

<!--AspectJ security config-->
<bean id="securityInterceptorAspectJ" class="org.acegisecurity.intercept.method.aspectj.AspectJSecurityInterceptor">
<property name="validateConfigAttributes" value="true" />
<property name="authenticationManager" ref="authenticationManager" />
<property name="accessDecisionManager" ref="accessDecisionManager" />
<property name="objectDefinitionSource">
<bean class="org.acegisecurity.intercept.method.MethodDefinitionAttributes">
<property name="attributes" ref="securityAttributes" />
</bean>
</property>
</bean>

<!-- ======================== FILTER CHAIN ======================= -->

<!-- If you wish to use channel security, add "channelProcessingFilter," in front
of "httpSessionContextIntegrationFilter" in the list below -->
<bean id="filterChainProxy" class="org.acegisecurity.util.FilterChainProxy">
<property name="filterInvocationDefinitionSource">
<value>
CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON
PATTERN_TYPE_APACHE_ANT
/**=httpSessionContextIntegrationFilter,logoutFilter,securityContextHolderAwareRequestFilter,x509ProcessingFilter,authenticationProcessingFilter,anonymousProcessingFilter,exceptionTranslationFilter,filterInvocationInterceptor
</value>
</property>
</bean>

<!-- ======================== AUTHENTICATION ======================= -->

<bean id="authenticationManager" class="org.acegisecurity.providers.ProviderManager">
<property name="providers">
<list>
<ref bean="x509AuthenticationProvider" />
<ref bean="ldapAuthenticationProvider" />
<ref bean="anonymousAuthenticationProvider" />
</list>
</property>
</bean>

<bean id="anonymousAuthenticationProvider"
class="org.acegisecurity.providers.anonymous.AnonymousAuthenticationProvider">
<property name="key" value="fakeAnonymousKey" />
<!--must match anonymousProcessingFilter key-->
</bean>

<bean id="x509AuthenticationProvider" class="org.acegisecurity.providers.x509.X509AuthenticationProvider">
<property name="x509AuthoritiesPopulator" ref="x509LdapAuthoritiesPopulator" />
<property name="x509UserCache" ref="userCache" />
</bean>

<bean id="ldapAuthenticationProvider" class="org.acegisecurity.providers.ldap.LdapAuthenticationProvider"
parent="checkerboardLdapProvider">
<property name="userCache" ref="userCache" />
</bean>

<bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean" />

<bean id="userCache" class="com.checkernet.security.EhCacheX509LdapUserCache">
<property name="cache">
<bean class="org.springframework.cache.ehcache.EhCacheFactoryBean">
<property name="cacheManager" ref="cacheManager" />
<property name="cacheName" value="x509Cache" />
</bean>
</property>
</bean>

<bean id="httpSessionContextIntegrationFilter"
class="org.acegisecurity.context.HttpSessionContextIntegrationFilter">
</bean>
<bean id="securityContextHolderAwareRequestFilter"
class="org.acegisecurity.wrapper.SecurityContextHolderAwareRequestFilter">
<property name="wrapperClass" value="com.checkernet.security.SecurityContextHolderAwareRequestWrapperWrapper" />
</bean>

<bean id="logoutFilter" class="org.acegisecurity.ui.logout.LogoutFilter">
<constructor-arg value="/" />
<constructor-arg>
<list>
<ref bean="securityContextLogoutHandler" />
<bean class="org.acegisecurity.ui.logout.SecurityContextLogoutHandler" />
</list>
</constructor-arg>
<property name="filterProcessesUrl" value="/app/logout" />
</bean>
<bean id="securityContextLogoutHandler"
class="org.acegisecurity.ui.logout.SecurityContextLogoutHandler">
</bean>


<!-- ===================== HTTP REQUEST SECURITY ==================== -->

<bean id="anonymousProcessingFilter" class="org.acegisecurity.providers.anonymous.AnonymousProcessingFilter">
<property name="key" value="fakeAnonymousKey" />
<!--must match anonymousAuthenticationProvider key-->
<property name="userAttribute" value="anonymousUser,ROLE_ANONYMOUS" />
</bean>

<bean id="exceptionTranslationFilter" class="org.acegisecurity.ui.ExceptionTranslationFilter">
<property name="authenticationEntryPoint">
<bean class="org.acegisecurity.ui.webapp.AuthenticationProcessingFilterEntryPoint">
<property name="loginFormUrl">
<value>/login</value>
</property>
<property name="forceHttps" value="true" />
</bean>
</property>
</bean>

<bean id="x509ProcessingFilter" class="org.acegisecurity.ui.x509.X509ProcessingFilter">
<property name="authenticationManager" ref="authenticationManager" />
</bean>

<bean id="authenticationProcessingFilter" class="org.acegisecurity.ui.webapp.AuthenticationProcessingFilter">
<property name="authenticationManager" ref="authenticationManager" />
<property name="authenticationFailureUrl">
<value>/login_error</value>
</property>
<property name="defaultTargetUrl">
<value>/</value>
</property>
<property name="filterProcessesUrl">
<value>/login/check</value>
</property>
</bean>

<bean id="httpRequestAccessDecisionManager" class="org.acegisecurity.vote.AffirmativeBased">
<property name="allowIfAllAbstainDecisions" value="false" />
<property name="decisionVoters">
<list>
<ref local="roleVoter" />
</list>
</property>
</bean>

<!-- Note the order that entries are placed against the objectDefinitionSource is critical.
The FilterSecurityInterceptor will work from the top of the list down to the FIRST pattern that matches the request URL.
Accordingly, you should place MOST SPECIFIC (ie a/b/c/d.*) expressions first, with LEAST SPECIFIC (ie a/.*) expressions last -->
<bean id="filterInvocationInterceptor" class="org.acegisecurity.intercept.web.FilterSecurityInterceptor">
<property name="authenticationManager" ref="authenticationManager" />
<property name="accessDecisionManager" ref="httpRequestAccessDecisionManager" />
<property name="objectDefinitionSource">
<value>
CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON
\A/login.*\Z=ROLE_ANONYMOUS,ROLE_PHOENIX
\A/app/SecuredPage.*\Z=ROLE_SUPERUSER
\A/app/AnotherSecuredPage.*\Z=ROLE_SUPERUSER
\A/.*\Z=ROLE_PHOENIX
</value>
</property>
</bean>
</beans>

Friday, November 17, 2006

Thursday, November 16, 2006

Auto Discovering Hibernate Entities

We're a good portion of the way through a rewrite of an Order Entry system using spring & hibernate and all and all love the technology. There a few places, in particular with configuration, that have forced us to have repetitive code.

One of these places is the annotated class list that we provide to spring's AnnotationSessionFactoryBean in order to let Hibernate know which classes it should persist. This seems repetitive since (almost, more on that later) every class we'd like to have on this list is marked by the @Entity annotation. The session factory bean just needs a list of classes so I thought why not make a factory bean to generate this list for me. This seemed a good place to start a little cleanup so I wrote the following test:


/**
* Test that we find annotated classes given a package
*/
public void testLoadWithPackageName() throws Exception {
AnnotatedClassDiscoveryBean discoveryBean = new AnnotatedClassDiscoveryBean();
discoveryBean.setAnnotation("com.checkernet.util.testpackage.Test");
discoveryBean.setPkg("com.checkernet.util.testpackage");
Object object = discoveryBean.getObject();
assertTrue("Returned object incorrect type", object instanceof List );
List classes = (List) object;
assertEquals("Incorrect number of classes found", 3, classes.size());
}



Then I created a test package, @Test annotation, and popped in a few classes. Two of these classes used the @Test annotation. Now it was time to create an AnnotatedClassDiscoveryBean.

For starters it implemented FactoryBean. After that I knew I needed to find a way to walk through a package and get all of the classes to see if they had the annotation or not. A quick search on google lead me to a discussion on topic. Between the code on page two and spring's included AnnotationClassFilter I had everything I needed to make my test green.

With that out of the way I decided to add some more functionality. If you're in the DomainDrivenDesign camp or just don't like to organize your packages by architectural function, it would be nice to give the discovery bean a top level package and recurse/iterate through to find entity classes in sub-packages. So I added a second test:


/**
* Test that we can find annotations in packages which are descendants of the
* supplied package
*/
public void testParentPackage() throws Exception {
AnnotatedClassDiscoveryBean discoveryBean = new AnnotatedClassDiscoveryBean();
discoveryBean.setAnnotation("com.checkernet.util.testpackage.Test");
discoveryBean.setPkg("com.checkernet");
Object object = discoveryBean.getObject();
assertTrue("Returned object incorrect type", object instanceof List );
List classes = (List) object;
assertEquals("Incorrect number of classes found", 2, classes.size());
}


As you could have guessed it didn't work. Looking at the code from that thread, it was a good starting point but:

1. It wasn't that clean
2. It didn't help me pass my second test

It looked like I needed to pull the code that generated a list of class apart from the code which checked for the annotation. I did this and tried to give the code a quick pass (although it is still pretty rough) but now it passes both tests. In my session factory spring configuration the annotatedClasses property referred to a list (bean) with an id of annotated classes. It used to look like this:

<util:list id="annotatedClassList" list-class="java.util.ArrayList">
com.checkernet.model.ActiveShipmentHold
com.checkernet.model.Carrier
com.checkernet.model.CarrierMethod
com.checkernet.model.CatalogPageEntry
... (and many many more)
</util:list id="annotatedClassList" list-class="java.util.ArrayList">


so I removed that bean and replaced it with:


<bean id="annotatedClassList" class="com.checkernet.util.AnnotatedClassDiscoveryBean">
<property name="pkg" value="com.checkernet.model" />
<property name="annotation" value="javax.persistence.Entity" />
</bean>





re-ran my tests and viola no more duplication.

Here is the current (notice I didn't say final) code for the AnnotatedClassDiscoveryBean:


This code could be used for things other than hiberate.


package com.checkernet.util;

import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.net.URL;
import static java.net.URLDecoder.decode;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aop.support.annotation.AnnotationClassFilter;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.util.Assert;


/**
* Notes: A factory bean which given a package name and fully qualified annotation class name will
* Return a list of classes. Developed for use with Spring for dynamic configuration of annotated classes
* properties (like the one in AbstractSessionFactoryBean)
*
* @author Dan Thiffault
* @version 0.0.1
*/
public class AnnotatedClassDiscoveryBean implements FactoryBean {

private final Log logger = LogFactory.getLog(AnnotatedClassDiscoveryBean.class);

private Class annotation;

private String packageName;

private static final int CLASS_LIST_SIZE = 50;

private static final char PACKAGE_SEPARATOR = '.';

/**
* this is specified by ClassLoader.getResources()
*/
@SuppressWarnings({"HardcodedFileSeparator"})
private static final char PATH_SEPARATOR = '/';

/**
* Set the annotation to search for
*
* @param annotationName of the Annotation class
* @throws ClassNotFoundException if that annotation is not on the classpath
*/
@Required
public void setAnnotation(final String annotationName) throws ClassNotFoundException {
//noinspection unchecked
annotation = (Class) Thread.currentThread().getContextClassLoader()
.loadClass(annotationName);
}

/**
* Set the base package name to search through. Sub-packages will be searched
*
* @param packageName to search through
*/
@Required
public void setPkg(final String packageName) {
this.packageName = packageName;
}

/**
* Returns a list of classes which are in the specified package, or
* one of its descedant packages, that have the specified annotation.
*
* @return List<Class> of classes found
*/
public final Object getObject() {
final List classes;

List allClasses = getClassesForPackage(packageName);
classes = new ArrayList(allClasses.size());
logger.debug("Found ".concat(Integer.toString(allClasses.size())).concat(" classes in package"));
AnnotationClassFilter filter = new AnnotationClassFilter(annotation);
for (Class clazz : allClasses) {
if (filter.matches(clazz)) {
logger.debug("Discovered class: ".concat(clazz.toString()));
classes.add(clazz);
}
}


return classes;
}

/**
* Returns a list class. This is the type the calls to getObject are
* guaranteed to return.
*
* @return Class of type List
*/
public Class getObjectType() {
return List.class;
}

/**
* {@inheritDoc}
*/
public boolean isSingleton() {
return false;
}

/**
* Attempts to list all the classes in the specified package as determined
* by the context class loader
*
* @param pckgname the package name to search
* @return a list of classes that exist within that package
*/
@SuppressWarnings({"MethodWithMultipleLoops"})
private static List getClassesForPackage(String pckgname) {
String path = pckgname.replace(PACKAGE_SEPARATOR, PATH_SEPARATOR);
// This will hold a list of directories matching the pckgname. There may be more than one if a package is split over multiple jars/paths

List directories = new ArrayList(CLASS_LIST_SIZE);
List jars = new ArrayList(CLASS_LIST_SIZE);

List classFiles = new ArrayList(CLASS_LIST_SIZE);

//noinspection ProhibitedExceptionCaught
try {
ClassLoader cld = Thread.currentThread().getContextClassLoader();
if (cld == null) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("Can't get class loader.");
}

// Ask for all resources for the path
Enumeration resources = cld.getResources(path);
//noinspection MethodCallInLoopCondition
while (resources.hasMoreElements()) {
//noinspection ObjectAllocationInLoop
String decodedPath = decode(resources.nextElement().getPath(), "UTF-8");
if (decodedPath.contains(".jar!")) {
String jarPathName = decodedPath.substring(5, decodedPath.indexOf("!"));
classFiles.addAll(getClassesInJar(new JarFile(jarPathName)));
} else {
if (!decodedPath.contains(".zip!")) {
directories.add(new File(decodedPath));
}
}
}
} catch (NullPointerException x) {
throw new IllegalArgumentException(
pckgname + " does not appear to be a valid package", x);
} catch (UnsupportedEncodingException encex) {
throw new IllegalArgumentException(
pckgname + " does not appear to be a valid package (Unsupported encoding)", encex);
} catch (IOException ioex) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("IOException was thrown when trying to get all resources for " + pckgname, ioex);

} catch (Exception e) {
throw new RuntimeException("Something terrible happened", e);
}

// For every directory identified capture all the .class files
for (File directory : directories) {
if (directory.exists()) {
classFiles.addAll(getFilesInDirectory(directory, path));
} else {
throw new IllegalArgumentException(
pckgname + " (" + directory.getPath() + ") does not appear to be a valid package");
}
}


List classes = new ArrayList(classFiles.size());

for (String fileName : classFiles) {
try {
classes.add(Class.forName(
fileName.substring(0, fileName.length() - 6).replace(File.separatorChar, PACKAGE_SEPARATOR).replace(PATH_SEPARATOR, PACKAGE_SEPARATOR)));

} catch (ClassNotFoundException e) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("Couldn't load class", e);
}
}

return classes;

}

private static List getFilesInDirectory(File directory, final String currentDirectory) {
Assert.isTrue(directory.isDirectory(), "File passed in was not a directory: ".concat(directory.getName()));
List files = new ArrayList(CLASS_LIST_SIZE);

for (File file : directory.listFiles()) {
String fileName = file.getName();
if (file.isFile() && fileName.endsWith(".class")) {
files.add(currentDirectory.concat(File.separator).concat(fileName));
} else if (file.isDirectory()) {
files.addAll(getFilesInDirectory(file, currentDirectory.concat(File.separator).concat(fileName)));
}
}

return files;
}

private static List getClassesInJar(JarFile jf) {
List classNames = new ArrayList(CLASS_LIST_SIZE);
Enumeration e = jf.entries();
while (e.hasMoreElements()) {
JarEntry jarEntry = e.nextElement();
if (jarEntry.getName().endsWith(".class")) {
classNames.add(jarEntry.getName());
}

}

return classNames;
}

}




Updated: Fixed to work with jars.
Update 2: Based on Joe's comment, updated the code to work on non-BSD based systems (like windows)