瀏覽代碼

Added Digest Authentication support (RFC 2617 and RFC 2069).

Ben Alex 20 年之前
父節點
當前提交
a3818184f4

+ 11 - 5
core/src/main/java/org/acegisecurity/ui/basicauth/BasicProcessingFilter.java

@@ -19,7 +19,6 @@ import net.sf.acegisecurity.Authentication;
 import net.sf.acegisecurity.AuthenticationException;
 import net.sf.acegisecurity.AuthenticationManager;
 import net.sf.acegisecurity.context.ContextHolder;
-import net.sf.acegisecurity.context.HttpSessionContextIntegrationFilter;
 import net.sf.acegisecurity.context.security.SecureContext;
 import net.sf.acegisecurity.context.security.SecureContextUtils;
 import net.sf.acegisecurity.intercept.web.AuthenticationEntryPoint;
@@ -46,7 +45,7 @@ import javax.servlet.http.HttpServletResponse;
 
 /**
  * Processes a HTTP request's BASIC authorization headers, putting the result
- * into the <code>HttpSession</code>.
+ * into the <code>ContextHolder</code>.
  * 
  * <P>
  * For a detailed background on what this filter is designed to process, refer
@@ -75,9 +74,7 @@ import javax.servlet.http.HttpServletResponse;
  * 
  * <P>
  * If authentication is successful, the resulting {@link Authentication} object
- * will be placed into the <code>HttpSession</code> with the attribute defined
- * by {@link
- * HttpSessionContextIntegrationFilter#ACEGI_SECURITY_AUTHENTICATION_KEY}.
+ * will be placed into the <code>ContextHolder</code>.
  * </p>
  * 
  * <p>
@@ -87,6 +84,15 @@ import javax.servlet.http.HttpServletResponse;
  * </p>
  * 
  * <P>
+ * Basic authentication is an attractive protocol because it is simple and
+ * widely deployed. However, it still transmits a password in clear text and
+ * as such is undesirable in many situations. Digest authentication is also
+ * provided by Acegi Security and should be used instead of Basic
+ * authentication wherever possible. See {@link
+ * net.sf.acegisecurity.ui.digestauth.DigestProcessingFilter}.
+ * </p>
+ * 
+ * <P>
  * <B>Do not use this class directly.</B> Instead configure
  * <code>web.xml</code> to use the {@link
  * net.sf.acegisecurity.util.FilterToBeanProxy}.

+ 452 - 0
core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilter.java

@@ -0,0 +1,452 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.ui.digestauth;
+
+import net.sf.acegisecurity.Authentication;
+import net.sf.acegisecurity.AuthenticationException;
+import net.sf.acegisecurity.AuthenticationServiceException;
+import net.sf.acegisecurity.BadCredentialsException;
+import net.sf.acegisecurity.UserDetails;
+import net.sf.acegisecurity.context.ContextHolder;
+import net.sf.acegisecurity.context.security.SecureContext;
+import net.sf.acegisecurity.context.security.SecureContextUtils;
+import net.sf.acegisecurity.intercept.web.AuthenticationEntryPoint;
+import net.sf.acegisecurity.providers.UsernamePasswordAuthenticationToken;
+import net.sf.acegisecurity.providers.dao.AuthenticationDao;
+import net.sf.acegisecurity.providers.dao.UserCache;
+import net.sf.acegisecurity.providers.dao.UsernameNotFoundException;
+import net.sf.acegisecurity.providers.dao.cache.NullUserCache;
+import net.sf.acegisecurity.ui.WebAuthenticationDetails;
+import net.sf.acegisecurity.util.StringSplitUtils;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.beans.factory.InitializingBean;
+
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+
+import java.util.Map;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+
+/**
+ * Processes a HTTP request's Digest authorization headers, putting the result
+ * into the <code>ContextHolder</code>.
+ * 
+ * <P>
+ * For a detailed background on what this filter is designed to process, refer
+ * to <A HREF="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</A> (which
+ * superseeded RFC 2069, although this filter support clients that implement
+ * either RFC 2617 or RFC 2069).
+ * </p>
+ * 
+ * <p>
+ * This filter can be used to provide Digest authentication services to both
+ * remoting protocol clients (such as Hessian and SOAP) as well as standard
+ * user agents (such as Internet Explorer and FireFox).
+ * </p>
+ * 
+ * <p>
+ * This Digest implementation has been designed to avoid needing to store
+ * session state between invocations. All session management information is
+ * stored in the "nonce" that is sent to the client by the {@link
+ * net.sf.acegisecurity.ui.digestauth.DigestProcessingFilterEntryPoint}.
+ * </p>
+ * 
+ * <P>
+ * If authentication is successful, the resulting {@link Authentication} object
+ * will be placed into the <code>ContextHolder</code>.
+ * </p>
+ * 
+ * <p>
+ * If authentication fails, an {@link AuthenticationEntryPoint} implementation
+ * is called. This must always be {@link DigestProcessingFilterEntryPoint},
+ * which will prompt the user to authenticate again via Digest authentication.
+ * </p>
+ * 
+ * <P>
+ * Note there are limitations to Digest authentication, although it is a more
+ * comprehensive and secure solution than Basic authentication. Please see RFC
+ * 2617 section 4 for a full discussion on the advantages of Digest
+ * authentication over Basic authentication, including commentary on the
+ * limitations that it still imposes.
+ * </p>
+ * 
+ * <P>
+ * <B>Do not use this class directly.</B> Instead configure
+ * <code>web.xml</code> to use the {@link
+ * net.sf.acegisecurity.util.FilterToBeanProxy}.
+ * </p>
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class DigestProcessingFilter implements Filter, InitializingBean {
+    //~ Static fields/initializers =============================================
+
+    private static final Log logger = LogFactory.getLog(DigestProcessingFilter.class);
+
+    //~ Instance fields ========================================================
+
+    private AuthenticationDao authenticationDao;
+    private DigestProcessingFilterEntryPoint authenticationEntryPoint;
+    private UserCache userCache = new NullUserCache();
+
+    //~ Methods ================================================================
+
+    public void setAuthenticationDao(AuthenticationDao authenticationDao) {
+        this.authenticationDao = authenticationDao;
+    }
+
+    public AuthenticationDao getAuthenticationDao() {
+        return authenticationDao;
+    }
+
+    public void setAuthenticationEntryPoint(
+        DigestProcessingFilterEntryPoint authenticationEntryPoint) {
+        this.authenticationEntryPoint = authenticationEntryPoint;
+    }
+
+    public DigestProcessingFilterEntryPoint getAuthenticationEntryPoint() {
+        return authenticationEntryPoint;
+    }
+
+    public void afterPropertiesSet() throws Exception {
+        if (this.authenticationDao == null) {
+            throw new IllegalArgumentException(
+                "An AuthenticationDao is required");
+        }
+
+        if (this.authenticationEntryPoint == null) {
+            throw new IllegalArgumentException(
+                "A DigestProcessingFilterEntryPoint is required");
+        }
+    }
+
+    public void destroy() {}
+
+    public void doFilter(ServletRequest request, ServletResponse response,
+        FilterChain chain) throws IOException, ServletException {
+        if (!(request instanceof HttpServletRequest)) {
+            throw new ServletException("Can only process HttpServletRequest");
+        }
+
+        if (!(response instanceof HttpServletResponse)) {
+            throw new ServletException("Can only process HttpServletResponse");
+        }
+
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+
+        String header = httpRequest.getHeader("Authorization");
+
+        if (logger.isDebugEnabled()) {
+            logger.debug("Authorization header received from user agent: "
+                + header);
+        }
+
+        if ((header != null) && header.startsWith("Digest ")) {
+            String section212response = header.substring(7);
+
+            String[] headerEntries = StringUtils
+                .commaDelimitedListToStringArray(section212response);
+            Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                    "=", "\"");
+
+            String username = (String) headerMap.get("username");
+            String realm = (String) headerMap.get("realm");
+            String nonce = (String) headerMap.get("nonce");
+            String uri = (String) headerMap.get("uri");
+            String responseDigest = (String) headerMap.get("response");
+            String qop = (String) headerMap.get("qop"); // RFC 2617 extension
+            String nc = (String) headerMap.get("nc"); // RFC 2617 extension
+            String cnonce = (String) headerMap.get("cnonce"); // RFC 2617 extension
+
+            // Check all required parameters were supplied (ie RFC 2069)
+            if ((username == null) || (realm == null) || (nonce == null)
+                || (uri == null) || (response == null)) {
+                if (logger.isDebugEnabled()) {
+                    logger.debug("extracted username: '" + username
+                        + "'; realm: '" + username + "'; nonce: '" + username
+                        + "'; uri: '" + username + "'; response: '" + username
+                        + "'");
+                }
+
+                fail(request, response,
+                    new BadCredentialsException(
+                        "Missing mandatory digest value; received header '"
+                        + section212response + "'"));
+
+                return;
+            }
+
+            // Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
+            if ("auth".equals(qop)) {
+                if ((nc == null) || (cnonce == null)) {
+                    if (logger.isDebugEnabled()) {
+                        logger.debug("extracted nc: '" + nc + "'; cnonce: '"
+                            + cnonce + "'");
+                    }
+
+                    fail(request, response,
+                        new BadCredentialsException(
+                            "Missing mandatory digest value for 'auth' QOP; received header '"
+                            + section212response + "'"));
+
+                    return;
+                }
+            }
+
+            // Check realm name equals what we expected
+            if (!this.getAuthenticationEntryPoint().getRealmName().equals(realm)) {
+                fail(request, response,
+                    new BadCredentialsException("Response realm name '" + realm
+                        + "' does not match system realm name of '"
+                        + this.getAuthenticationEntryPoint().getRealmName()
+                        + "'"));
+
+                return;
+            }
+
+            // Check nonce was a Base64 encoded (as sent by DigestProcessingFilterEntryPoint)
+            if (!Base64.isArrayByteBase64(nonce.getBytes())) {
+                fail(request, response,
+                    new BadCredentialsException(
+                        "None is not encoded in Base64; received nonce: '"
+                        + nonce + "'"));
+
+                return;
+            }
+
+            // Decode nonce from Base64
+            // format of nonce is:  
+            //   base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
+            String nonceAsPlainText = new String(Base64.decodeBase64(
+                        nonce.getBytes()));
+            String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText,
+                    ":");
+
+            if (nonceTokens.length != 2) {
+                fail(request, response,
+                    new BadCredentialsException(
+                        "Nonce should have yielded two tokens but was: '"
+                        + nonceAsPlainText + "'"));
+
+                return;
+            }
+
+            // Extract expiry time from nonce
+            long nonceExpiryTime;
+
+            try {
+                nonceExpiryTime = new Long(nonceTokens[0]).longValue();
+            } catch (NumberFormatException nfe) {
+                fail(request, response,
+                    new BadCredentialsException(
+                        "Nonce token should have yielded a numeric first token, but was: '"
+                        + nonceAsPlainText + "'"));
+
+                return;
+            }
+
+            // Check signature of nonce matches this expiry time
+            String expectedNonceSignature = DigestUtils.md5Hex(nonceExpiryTime
+                    + ":" + this.getAuthenticationEntryPoint().getKey());
+
+            if (!expectedNonceSignature.equals(nonceTokens[1])) {
+                fail(request, response,
+                    new BadCredentialsException("Nonce token compromised: '"
+                        + nonceAsPlainText + "'"));
+
+                return;
+            }
+
+            // Lookup password for presented username
+            // NB: DAO-provided password MUST be clear text - not encoded/salted
+            boolean loadedFromDao = false;
+            UserDetails user = userCache.getUserFromCache(username);
+
+            if (user == null) {
+                loadedFromDao = true;
+
+                try {
+                    user = authenticationDao.loadUserByUsername(username);
+                } catch (UsernameNotFoundException notFound) {
+                    fail(request, response,
+                        new BadCredentialsException("Username '" + username
+                            + "' not known"));
+
+                    return;
+                }
+
+                if (user == null) {
+                    throw new AuthenticationServiceException(
+                        "AuthenticationDao returned null, which is an interface contract violation");
+                }
+
+                userCache.putUserInCache(user);
+            }
+
+            // Compute the expected response-digest (will be in hex form)
+            String serverDigestMd5;
+
+            // Don't catch IllegalArgumentException (already checked validity)
+            serverDigestMd5 = generateDigest(username, realm,
+                    user.getPassword(),
+                    ((HttpServletRequest) request).getMethod(), uri, qop,
+                    nonce, nc, cnonce);
+
+            // If digest is incorrect, try refreshing from backend and recomputing
+            if (!serverDigestMd5.equals(responseDigest) && !loadedFromDao) {
+                if (logger.isDebugEnabled()) {
+                    logger.debug(
+                        "Digest comparison failure; trying to refresh user from DAO in case password had changed");
+                }
+
+                try {
+                    user = authenticationDao.loadUserByUsername(username);
+                } catch (UsernameNotFoundException notFound) {
+                    // Would very rarely happen, as user existed earlier
+                    fail(request, response,
+                        new BadCredentialsException("Username '" + username
+                            + "' not known"));
+                }
+
+                userCache.putUserInCache(user);
+
+                // Don't catch IllegalArgumentException (already checked validity)
+                serverDigestMd5 = generateDigest(username, realm,
+                        user.getPassword(),
+                        ((HttpServletRequest) request).getMethod(), uri, qop,
+                        nonce, nc, cnonce);
+            }
+
+            // If digest is still incorrect, definitely reject authentication attempt
+            if (!serverDigestMd5.equals(responseDigest)) {
+                if (logger.isDebugEnabled()) {
+                    logger.debug("Expected response: '" + serverDigestMd5
+                        + "' but received: '" + responseDigest
+                        + "'; is AuthenticationDao returning clear text passwords?");
+                }
+
+                fail(request, response,
+                    new BadCredentialsException("Incorrect response"));
+
+                return;
+            }
+
+            // To get this far, the digest must have been valid
+            // Check the nonce has not expired
+            // We do this last so we can direct the user agent its nonce is stale
+            // but the request was otherwise appearing to be valid
+            if (nonceExpiryTime < System.currentTimeMillis()) {
+                fail(request, response,
+                    new NonceExpiredException("Nonce has expired/timed out"));
+
+                return;
+            }
+
+            if (logger.isDebugEnabled()) {
+                logger.debug("Authentication success for user: '" + username
+                    + "' with response: '" + responseDigest + "'");
+            }
+
+            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(user,
+                    user.getPassword());
+            authRequest.setDetails(new WebAuthenticationDetails(httpRequest));
+
+            SecureContext sc = SecureContextUtils.getSecureContext();
+            sc.setAuthentication(authRequest);
+            ContextHolder.setContext(sc);
+        }
+
+        chain.doFilter(request, response);
+    }
+
+    /**
+     * Computes the <code>response</code> portion of a Digest authentication
+     * header. Both the server and user agent should compute the
+     * <code>response</code> independently. Provided as a static method to
+     * simply the coding of user agents.
+     *
+     * @param username DOCUMENT ME!
+     * @param realm DOCUMENT ME!
+     * @param password DOCUMENT ME!
+     * @param httpMethod DOCUMENT ME!
+     * @param uri DOCUMENT ME!
+     * @param qop DOCUMENT ME!
+     * @param nonce DOCUMENT ME!
+     * @param nc DOCUMENT ME!
+     * @param cnonce DOCUMENT ME!
+     *
+     * @return the MD5 of the digest authentication response, encoded in hex
+     *
+     * @throws IllegalArgumentException DOCUMENT ME!
+     */
+    public static String generateDigest(String username, String realm,
+        String password, String httpMethod, String uri, String qop,
+        String nonce, String nc, String cnonce) throws IllegalArgumentException {
+        String a1 = username + ":" + realm + ":" + password;
+        String a2 = httpMethod + ":" + uri;
+        String a1Md5 = new String(DigestUtils.md5Hex(a1));
+        String a2Md5 = new String(DigestUtils.md5Hex(a2));
+
+        String digest;
+
+        if (qop == null) {
+            // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
+            digest = a1Md5 + ":" + nonce + ":" + a2Md5;
+        } else if ("auth".equals(qop)) {
+            // As per RFC 2617 compliant clients
+            digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop
+                + ":" + a2Md5;
+        } else {
+            throw new IllegalArgumentException(
+                "This method does not support a qop: '" + qop + "'");
+        }
+
+        String digestMd5 = new String(DigestUtils.md5Hex(digest));
+
+        return digestMd5;
+    }
+
+    public void init(FilterConfig arg0) throws ServletException {}
+
+    private void fail(ServletRequest request, ServletResponse response,
+        AuthenticationException failed) throws IOException, ServletException {
+        SecureContext sc = SecureContextUtils.getSecureContext();
+        sc.setAuthentication(null);
+        ContextHolder.setContext(sc);
+
+        if (logger.isDebugEnabled()) {
+            logger.debug(failed);
+        }
+
+        authenticationEntryPoint.commence(request, response, failed);
+    }
+}

+ 137 - 0
core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPoint.java

@@ -0,0 +1,137 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.ui.digestauth;
+
+import net.sf.acegisecurity.AuthenticationException;
+import net.sf.acegisecurity.intercept.web.AuthenticationEntryPoint;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.beans.factory.InitializingBean;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+
+
+/**
+ * Used by the <code>SecurityEnforcementFilter</code> to commence
+ * authentication via the {@link DigestProcessingFilter}.
+ * 
+ * <p>
+ * The nonce sent back to the user agent will be valid for the period indicated
+ * by {@link #setNonceValiditySeconds(int)}. By default this is 300 seconds.
+ * Shorter times should be used if replay attacks are a major concern. Larger
+ * values can be used if performance is a greater concern. This class
+ * correctly presents the <code>stale=true</code> header when the nonce has
+ * expierd, so properly implemented user agents will automatically renegotiate
+ * with a new nonce value (ie without presenting a new password dialog box to
+ * the user).
+ * </p>
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class DigestProcessingFilterEntryPoint
+    implements AuthenticationEntryPoint, InitializingBean {
+    //~ Static fields/initializers =============================================
+
+    private static final Log logger = LogFactory.getLog(DigestProcessingFilterEntryPoint.class);
+
+    //~ Instance fields ========================================================
+
+    private String key;
+    private String realmName;
+    private int nonceValiditySeconds = 300;
+
+    //~ Methods ================================================================
+
+    public void setKey(String key) {
+        this.key = key;
+    }
+
+    public String getKey() {
+        return key;
+    }
+
+    public void setNonceValiditySeconds(int nonceValiditySeconds) {
+        this.nonceValiditySeconds = nonceValiditySeconds;
+    }
+
+    public int getNonceValiditySeconds() {
+        return nonceValiditySeconds;
+    }
+
+    public void setRealmName(String realmName) {
+        this.realmName = realmName;
+    }
+
+    public String getRealmName() {
+        return realmName;
+    }
+
+    public void afterPropertiesSet() throws Exception {
+        if ((realmName == null) || "".equals(realmName)) {
+            throw new IllegalArgumentException("realmName must be specified");
+        }
+
+        if ((key == null) || "".equals(key)) {
+            throw new IllegalArgumentException("key must be specified");
+        }
+    }
+
+    public void commence(ServletRequest request, ServletResponse response,
+        AuthenticationException authException)
+        throws IOException, ServletException {
+        HttpServletResponse httpResponse = (HttpServletResponse) response;
+
+        // compute a nonce (do not use remote IP address due to proxy farms)
+        // format of nonce is:  
+        //   base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
+        long expiryTime = System.currentTimeMillis()
+            + (nonceValiditySeconds * 1000);
+        String signatureValue = new String(DigestUtils.md5Hex(expiryTime + ":"
+                    + key));
+        String nonceValue = expiryTime + ":" + signatureValue;
+        String nonceValueBase64 = new String(Base64.encodeBase64(
+                    nonceValue.getBytes()));
+
+        // qop is quality of protection, as defined by RFC 2617.
+        // we do not use opaque due to IE violation of RFC 2617 in not
+        // representing opaque on subsequent requests in same session.
+        String authenticateHeader = "Digest realm=\"" + realmName + "\", "
+            + "qop=\"auth\", nonce=\"" + nonceValueBase64 + "\"";
+
+        if (authException instanceof NonceExpiredException) {
+            authenticateHeader = authenticateHeader + ", stale=\"true\"";
+        }
+
+        if (logger.isDebugEnabled()) {
+            logger.debug("WWW-Authenticate header sent to user agent: "
+                + authenticateHeader);
+        }
+
+        httpResponse.addHeader("WWW-Authenticate", authenticateHeader);
+        httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED,
+            authException.getMessage());
+    }
+}

+ 51 - 0
core/src/main/java/org/acegisecurity/ui/digestauth/NonceExpiredException.java

@@ -0,0 +1,51 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.ui.digestauth;
+
+import net.sf.acegisecurity.AuthenticationException;
+
+
+/**
+ * Thrown if an authentication request is rejected because the digest nonce has
+ * expired.
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class NonceExpiredException extends AuthenticationException {
+    //~ Constructors ===========================================================
+
+    /**
+     * Constructs a <code>NonceExpiredException</code> with the specified
+     * message.
+     *
+     * @param msg the detail message
+     */
+    public NonceExpiredException(String msg) {
+        super(msg);
+    }
+
+    /**
+     * Constructs a <code>NonceExpiredException</code> with the specified
+     * message and root cause.
+     *
+     * @param msg the detail message
+     * @param t root cause
+     */
+    public NonceExpiredException(String msg, Throwable t) {
+        super(msg, t);
+    }
+}

+ 5 - 0
core/src/main/java/org/acegisecurity/ui/digestauth/package.html

@@ -0,0 +1,5 @@
+<html>
+<body>
+Authenticates HTTP Digest authentication requests.
+</body>
+</html>

+ 123 - 0
core/src/main/java/org/acegisecurity/util/StringSplitUtils.java

@@ -0,0 +1,123 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.util;
+
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+
+/**
+ * Provides several <code>String</code> manipulation methods.
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class StringSplitUtils {
+    //~ Methods ================================================================
+
+    /**
+     * Splits a <code>String</code> at the first instance of the delimiter.
+     * 
+     * <p>
+     * Does not include the delimiter in the response.
+     * </p>
+     *
+     * @param toSplit the string to split
+     * @param delimiter to split the string up with
+     *
+     * @return a two element array with index 0 being before the delimiter, and
+     *         index 1 being after the delimiter (neither element includes the
+     *         delimiter)
+     *
+     * @throws IllegalArgumentException if an argument was invalid
+     */
+    public static String[] split(String toSplit, String delimiter) {
+        Assert.hasLength(toSplit, "Cannot split a null or empty string");
+        Assert.hasLength(delimiter,
+            "Cannot use a null or empty delimiter to split a string");
+
+        if (delimiter.length() != 1) {
+            throw new IllegalArgumentException(
+                "Delimiter can only be one character in length");
+        }
+
+        int offset = toSplit.indexOf('=');
+
+        if (offset < 0) {
+            return null;
+        }
+
+        String beforeDelimiter = toSplit.substring(0, offset);
+        String afterDelimiter = toSplit.substring(offset + 1);
+
+        return new String[] {beforeDelimiter, afterDelimiter};
+    }
+
+    /**
+     * Takes an array of <code>String</code>s, and for each element removes any
+     * instances of <code>removeCharacter</code>, and splits the element based
+     * on the <code>delimiter</code>. A <code>Map</code> is then generated,
+     * with the left of the delimiter providing the key, and the right of the
+     * delimiter providing the value.
+     * 
+     * <p>
+     * Will trim both the key and value before adding to the <code>Map</code>.
+     * </p>
+     *
+     * @param array the array to process
+     * @param delimiter to split each element using (typically the equals
+     *        symbol)
+     * @param removeCharacters one or more characters to remove from each
+     *        element prior to attempting the split operation (typically the
+     *        quotation mark symbol) or <code>null</code> if no removal should
+     *        occur
+     *
+     * @return a <code>Map</code> representing the array contents, or
+     *         <code>null</code> if the array to process was null or empty
+     */
+    public static Map splitEachArrayElementAndCreateMap(String[] array,
+        String delimiter, String removeCharacters) {
+        if ((array == null) || (array.length == 0)) {
+            return null;
+        }
+
+        Map map = new HashMap();
+
+        for (int i = 0; i < array.length; i++) {
+            String postRemove;
+
+            if (removeCharacters == null) {
+                postRemove = array[i];
+            } else {
+                postRemove = StringUtils.replace(array[i], removeCharacters, "");
+            }
+
+            String[] splitThisArrayElement = split(postRemove, delimiter);
+
+            if (splitThisArrayElement == null) {
+                continue;
+            }
+
+            map.put(splitThisArrayElement[0].trim(),
+                splitThisArrayElement[1].trim());
+        }
+
+        return map;
+    }
+}

+ 1 - 1
core/src/test/java/org/acegisecurity/MockHttpServletRequest.java

@@ -171,7 +171,7 @@ public class MockHttpServletRequest implements HttpServletRequest {
     }
 
     public String getMethod() {
-        throw new UnsupportedOperationException("mock method not implemented");
+        return "GET";
     }
 
     public void setParameter(String arg0, String value) {

+ 172 - 0
core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPointTests.java

@@ -0,0 +1,172 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.ui.digestauth;
+
+import junit.framework.TestCase;
+
+import net.sf.acegisecurity.DisabledException;
+import net.sf.acegisecurity.MockHttpServletRequest;
+import net.sf.acegisecurity.MockHttpServletResponse;
+import net.sf.acegisecurity.util.StringSplitUtils;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.digest.DigestUtils;
+
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+
+
+/**
+ * Tests {@link DigestProcessingFilterEntryPoint}.
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class DigestProcessingFilterEntryPointTests extends TestCase {
+    //~ Constructors ===========================================================
+
+    public DigestProcessingFilterEntryPointTests() {
+        super();
+    }
+
+    public DigestProcessingFilterEntryPointTests(String arg0) {
+        super(arg0);
+    }
+
+    //~ Methods ================================================================
+
+    public final void setUp() throws Exception {
+        super.setUp();
+    }
+
+    public static void main(String[] args) {
+        junit.textui.TestRunner.run(DigestProcessingFilterEntryPointTests.class);
+    }
+
+    public void testDetectsMissingKey() throws Exception {
+        DigestProcessingFilterEntryPoint ep = new DigestProcessingFilterEntryPoint();
+        ep.setRealmName("realm");
+
+        try {
+            ep.afterPropertiesSet();
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertEquals("key must be specified", expected.getMessage());
+        }
+    }
+
+    public void testDetectsMissingRealmName() throws Exception {
+        DigestProcessingFilterEntryPoint ep = new DigestProcessingFilterEntryPoint();
+        ep.setKey("dcdc");
+        ep.setNonceValiditySeconds(12);
+
+        try {
+            ep.afterPropertiesSet();
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertEquals("realmName must be specified", expected.getMessage());
+        }
+    }
+
+    public void testGettersSetters() {
+        DigestProcessingFilterEntryPoint ep = new DigestProcessingFilterEntryPoint();
+        assertEquals(300, ep.getNonceValiditySeconds()); // 5 mins default
+        ep.setRealmName("realm");
+        assertEquals("realm", ep.getRealmName());
+        ep.setKey("dcdc");
+        assertEquals("dcdc", ep.getKey());
+        ep.setNonceValiditySeconds(12);
+        assertEquals(12, ep.getNonceValiditySeconds());
+    }
+
+    public void testNormalOperation() throws Exception {
+        DigestProcessingFilterEntryPoint ep = new DigestProcessingFilterEntryPoint();
+        ep.setRealmName("hello");
+        ep.setKey("key");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(
+                "/some_path");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        ep.afterPropertiesSet();
+
+        ep.commence(request, response, new DisabledException("foobar"));
+
+        // Check response is properly formed
+        assertEquals(401, response.getError());
+        assertTrue(response.getHeader("WWW-Authenticate").startsWith("Digest "));
+
+        // Break up response header
+        String header = response.getHeader("WWW-Authenticate").substring(7);
+        String[] headerEntries = StringUtils.commaDelimitedListToStringArray(header);
+        Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                "=", "\"");
+
+        assertEquals("hello", headerMap.get("realm"));
+        assertEquals("auth", headerMap.get("qop"));
+        assertNull(headerMap.get("stale"));
+
+        checkNonceValid((String) headerMap.get("nonce"));
+    }
+
+    public void testOperationIfDueToStaleNonce() throws Exception {
+        DigestProcessingFilterEntryPoint ep = new DigestProcessingFilterEntryPoint();
+        ep.setRealmName("hello");
+        ep.setKey("key");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(
+                "/some_path");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        ep.afterPropertiesSet();
+
+        ep.commence(request, response,
+            new NonceExpiredException("expired nonce"));
+
+        // Check response is properly formed
+        assertEquals(401, response.getError());
+        assertTrue(response.getHeader("WWW-Authenticate").startsWith("Digest "));
+
+        // Break up response header
+        String header = response.getHeader("WWW-Authenticate").substring(7);
+        String[] headerEntries = StringUtils.commaDelimitedListToStringArray(header);
+        Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                "=", "\"");
+
+        assertEquals("hello", headerMap.get("realm"));
+        assertEquals("auth", headerMap.get("qop"));
+        assertEquals("true", headerMap.get("stale"));
+
+        checkNonceValid((String) headerMap.get("nonce"));
+    }
+
+    private void checkNonceValid(String nonce) {
+        // Check the nonce seems to be generated correctly
+        // format of nonce is:  
+        //   base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
+        assertTrue(Base64.isArrayByteBase64(nonce.getBytes()));
+
+        String decodedNonce = new String(Base64.decodeBase64(nonce.getBytes()));
+        String[] nonceTokens = StringUtils.delimitedListToStringArray(decodedNonce,
+                ":");
+        assertEquals(2, nonceTokens.length);
+
+        String expectedNonceSignature = DigestUtils.md5Hex(nonceTokens[0] + ":"
+                + "key");
+        assertEquals(expectedNonceSignature, nonceTokens[1]);
+    }
+}

+ 887 - 0
core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterTests.java

@@ -0,0 +1,887 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.ui.digestauth;
+
+import junit.framework.TestCase;
+
+import net.sf.acegisecurity.DisabledException;
+import net.sf.acegisecurity.MockFilterConfig;
+import net.sf.acegisecurity.MockHttpServletRequest;
+import net.sf.acegisecurity.MockHttpServletResponse;
+import net.sf.acegisecurity.MockHttpSession;
+import net.sf.acegisecurity.UserDetails;
+import net.sf.acegisecurity.context.ContextHolder;
+import net.sf.acegisecurity.context.security.SecureContextImpl;
+import net.sf.acegisecurity.context.security.SecureContextUtils;
+import net.sf.acegisecurity.providers.dao.AuthenticationDao;
+import net.sf.acegisecurity.providers.dao.UsernameNotFoundException;
+import net.sf.acegisecurity.util.StringSplitUtils;
+
+import org.apache.commons.codec.binary.Base64;
+
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.support.ClassPathXmlApplicationContext;
+
+import org.springframework.dao.DataAccessException;
+
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+
+
+/**
+ * Tests {@link DigestProcessingFilter}.
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class DigestProcessingFilterTests extends TestCase {
+    //~ Constructors ===========================================================
+
+    public DigestProcessingFilterTests() {
+        super();
+    }
+
+    public DigestProcessingFilterTests(String arg0) {
+        super(arg0);
+    }
+
+    //~ Methods ================================================================
+
+    public static void main(String[] args) {
+        junit.textui.TestRunner.run(DigestProcessingFilterTests.class);
+    }
+
+    public void testDoFilterWithNonHttpServletRequestDetected()
+        throws Exception {
+        DigestProcessingFilter filter = new DigestProcessingFilter();
+
+        try {
+            filter.doFilter(null, new MockHttpServletResponse(),
+                new MockFilterChain());
+            fail("Should have thrown ServletException");
+        } catch (ServletException expected) {
+            assertEquals("Can only process HttpServletRequest",
+                expected.getMessage());
+        }
+    }
+
+    public void testDoFilterWithNonHttpServletResponseDetected()
+        throws Exception {
+        DigestProcessingFilter filter = new DigestProcessingFilter();
+
+        try {
+            filter.doFilter(new MockHttpServletRequest(null, null), null,
+                new MockFilterChain());
+            fail("Should have thrown ServletException");
+        } catch (ServletException expected) {
+            assertEquals("Can only process HttpServletResponse",
+                expected.getMessage());
+        }
+    }
+
+    public void testExpiredNonceReturnsForbiddenWithStaleHeader()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(0);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+
+        String header = response.getHeader("WWW-Authenticate").substring(7);
+        String[] headerEntries = StringUtils.commaDelimitedListToStringArray(header);
+        Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                "=", "\"");
+        assertEquals("true", headerMap.get("stale"));
+    }
+
+    public void testFilterIgnoresRequestsContainingNoAuthorizationHeader()
+        throws Exception {
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+    }
+
+    public void testGettersSetters() {
+        DigestProcessingFilter filter = new DigestProcessingFilter();
+        filter.setAuthenticationDao(new MockAuthenticationDao());
+        assertTrue(filter.getAuthenticationDao() != null);
+
+        filter.setAuthenticationEntryPoint(new DigestProcessingFilterEntryPoint());
+        assertTrue(filter.getAuthenticationEntryPoint() != null);
+    }
+
+    public void testInvalidDigestAuthorizationTokenGeneratesError()
+        throws Exception {
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        String token = "NOT_A_VALID_TOKEN_AS_MISSING_COLON";
+        headers.put("Authorization",
+            "Digest " + new String(Base64.encodeBase64(token.getBytes())));
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(false);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+        assertEquals(401, response.getError());
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+    }
+
+    public void testMalformedHeaderReturnsForbidden() throws Exception {
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization", "Digest scsdcsdc");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testNonBase64EncodedNonceReturnsForbidden()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = "NOT_BASE_64_ENCODED";
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testNonceWithIncorrectSignatureForNumericFieldReturnsForbidden()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = new String(Base64.encodeBase64(
+                    "123456:incorrectStringPassword".getBytes()));
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(false);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testNonceWithNonNumericFirstElementReturnsForbidden()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = new String(Base64.encodeBase64(
+                    "hello:ignoredSecondElement".getBytes()));
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testNonceWithoutTwoColonSeparatedElementsReturnsForbidden()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = new String(Base64.encodeBase64(
+                    "a base 64 string without a colon".getBytes()));
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testNormalOperation() throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNotNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals("marissa",
+            ((UserDetails) SecureContextUtils.getSecureContext()
+                                             .getAuthentication().getPrincipal())
+            .getUsername());
+    }
+
+    public void testOtherAuthorizationSchemeIsIgnored()
+        throws Exception {
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization", "SOME_OTHER_AUTHENTICATION_SCHEME");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+    }
+
+    public void testStartupDetectsMissingAuthenticationDao()
+        throws Exception {
+        try {
+            DigestProcessingFilter filter = new DigestProcessingFilter();
+            filter.setAuthenticationEntryPoint(new DigestProcessingFilterEntryPoint());
+            filter.afterPropertiesSet();
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertEquals("An AuthenticationDao is required",
+                expected.getMessage());
+        }
+    }
+
+    public void testStartupDetectsMissingAuthenticationEntryPoint()
+        throws Exception {
+        try {
+            DigestProcessingFilter filter = new DigestProcessingFilter();
+            filter.setAuthenticationDao(new MockAuthenticationDao());
+            filter.afterPropertiesSet();
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertEquals("A DigestProcessingFilterEntryPoint is required",
+                expected.getMessage());
+        }
+    }
+
+    public void testSuccessLoginThenFailureLoginResultsInSessionLoosingToken()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNotNull(SecureContextUtils.getSecureContext().getAuthentication());
+
+        // Now retry, giving an invalid nonce
+        password = "WRONG_PASSWORD";
+        responseDigest = DigestProcessingFilter.generateDigest(username, realm,
+                password, "GET", uri, qop, nonce, nc, cnonce);
+
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        request = new MockHttpServletRequest(headers, null,
+                new MockHttpSession());
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        // Check we lost our previous authentication
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testWrongCnonceBasedOnDigestReturnsForbidden()
+        throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "NOT_SAME_AS_USED_FOR_DIGEST_COMPUTATION";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, "DIFFERENT_CNONCE");
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testWrongDigestReturnsForbidden() throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "WRONG_PASSWORD";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testWrongRealmReturnsForbidden() throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "marissa";
+        String realm = "WRONG_REALM";
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    public void testWrongUsernameReturnsForbidden() throws Exception {
+        Map responseHeaderMap = generateValidHeaders(60);
+
+        String username = "NOT_A_KNOWN_USER";
+        String realm = (String) responseHeaderMap.get("realm");
+        String nonce = (String) responseHeaderMap.get("nonce");
+        String uri = "/some_file.html";
+        String qop = (String) responseHeaderMap.get("qop");
+        String nc = "00000002";
+        String cnonce = "c822c727a648aba7";
+        String password = "koala";
+        String responseDigest = DigestProcessingFilter.generateDigest(username,
+                realm, password, "GET", uri, qop, nonce, nc, cnonce);
+
+        // Setup our HTTP request
+        Map headers = new HashMap();
+        headers.put("Authorization",
+            "Digest username=\"" + username + "\", realm=\"" + realm
+            + "\", nonce=\"" + nonce + "\", uri=\"" + uri + "\", response=\""
+            + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\""
+            + cnonce + "\"");
+
+        MockHttpServletRequest request = new MockHttpServletRequest(headers,
+                null, new MockHttpSession());
+        request.setServletPath("/some_file.html");
+
+        // Launch an application context and access our bean
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilter filter = (DigestProcessingFilter) ctx.getBean(
+                "digestProcessingFilter");
+
+        // Setup our filter configuration
+        MockFilterConfig config = new MockFilterConfig();
+
+        // Setup our expectation that the filter chain will be invoked
+        MockFilterChain chain = new MockFilterChain(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // Test
+        executeFilterInContainerSimulator(config, filter, request, response,
+            chain);
+
+        assertNull(SecureContextUtils.getSecureContext().getAuthentication());
+        assertEquals(401, response.getError());
+    }
+
+    protected void setUp() throws Exception {
+        super.setUp();
+        ContextHolder.setContext(new SecureContextImpl());
+    }
+
+    protected void tearDown() throws Exception {
+        super.tearDown();
+        ContextHolder.setContext(null);
+    }
+
+    private void executeFilterInContainerSimulator(FilterConfig filterConfig,
+        Filter filter, ServletRequest request, ServletResponse response,
+        FilterChain filterChain) throws ServletException, IOException {
+        filter.init(filterConfig);
+        filter.doFilter(request, response, filterChain);
+        filter.destroy();
+    }
+
+    private Map generateValidHeaders(int nonceValidityPeriod)
+        throws Exception {
+        ApplicationContext ctx = new ClassPathXmlApplicationContext(
+                "net/sf/acegisecurity/ui/digestauth/filtertest-valid.xml");
+        DigestProcessingFilterEntryPoint ep = (DigestProcessingFilterEntryPoint) ctx
+            .getBean("digestProcessingFilterEntryPoint");
+        ep.setNonceValiditySeconds(nonceValidityPeriod);
+
+        MockHttpServletRequest request = new MockHttpServletRequest(
+                "/some_path");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        ep.commence(request, response, new DisabledException("foobar"));
+
+        // Break up response header
+        String header = response.getHeader("WWW-Authenticate").substring(7);
+        String[] headerEntries = StringUtils.commaDelimitedListToStringArray(header);
+        Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                "=", "\"");
+
+        return headerMap;
+    }
+
+    //~ Inner Classes ==========================================================
+
+    private class MockAuthenticationDao implements AuthenticationDao {
+        public UserDetails loadUserByUsername(String username)
+            throws UsernameNotFoundException, DataAccessException {
+            return null;
+        }
+    }
+
+    private class MockFilterChain implements FilterChain {
+        private boolean expectToProceed;
+
+        public MockFilterChain(boolean expectToProceed) {
+            this.expectToProceed = expectToProceed;
+        }
+
+        private MockFilterChain() {
+            super();
+        }
+
+        public void doFilter(ServletRequest request, ServletResponse response)
+            throws IOException, ServletException {
+            if (expectToProceed) {
+                assertTrue(true);
+            } else {
+                fail("Did not expect filter chain to proceed");
+            }
+        }
+    }
+}

+ 140 - 0
core/src/test/java/org/acegisecurity/util/StringSplitUtilsTests.java

@@ -0,0 +1,140 @@
+/* Copyright 2004, 2005 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.sf.acegisecurity.util;
+
+import junit.framework.TestCase;
+
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+
+
+/**
+ * Tests {@link net.sf.acegisecurity.util.StringSplitUtils}.
+ *
+ * @author Ben Alex
+ * @version $Id$
+ */
+public class StringSplitUtilsTests extends TestCase {
+    //~ Constructors ===========================================================
+
+    // ===========================================================
+    public StringSplitUtilsTests() {
+        super();
+    }
+
+    public StringSplitUtilsTests(String arg0) {
+        super(arg0);
+    }
+
+    //~ Methods ================================================================
+
+    // ================================================================
+    public static void main(String[] args) {
+        junit.textui.TestRunner.run(StringSplitUtilsTests.class);
+    }
+
+    public void testSplitEachArrayElementAndCreateMapNormalOperation() {
+        // note it ignores malformed entries (ie those without an equals sign)
+        String unsplit = "username=\"marissa\", invalidEntryThatHasNoEqualsSign, realm=\"Contacts Realm\", nonce=\"MTEwOTAyMzU1MTQ4NDo1YzY3OWViYWM5NDNmZWUwM2UwY2NmMDBiNDQzMTQ0OQ==\", uri=\"/acegi-security-sample-contacts-filter/secure/adminPermission.htm?contactId=4\", response=\"38644211cf9ac3da63ab639807e2baff\", qop=auth, nc=00000004, cnonce=\"2b8d329a8571b99a\"";
+        String[] headerEntries = StringUtils.commaDelimitedListToStringArray(unsplit);
+        Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                "=", "\"");
+
+        assertEquals("marissa", headerMap.get("username"));
+        assertEquals("Contacts Realm", headerMap.get("realm"));
+        assertEquals("MTEwOTAyMzU1MTQ4NDo1YzY3OWViYWM5NDNmZWUwM2UwY2NmMDBiNDQzMTQ0OQ==",
+            headerMap.get("nonce"));
+        assertEquals("/acegi-security-sample-contacts-filter/secure/adminPermission.htm?contactId=4",
+            headerMap.get("uri"));
+        assertEquals("38644211cf9ac3da63ab639807e2baff",
+            headerMap.get("response"));
+        assertEquals("auth", headerMap.get("qop"));
+        assertEquals("00000004", headerMap.get("nc"));
+        assertEquals("2b8d329a8571b99a", headerMap.get("cnonce"));
+        assertEquals(8, headerMap.size());
+    }
+
+    public void testSplitEachArrayElementAndCreateMapRespectsInstructionNotToRemoveCharacters() {
+        String unsplit = "username=\"marissa\", realm=\"Contacts Realm\", nonce=\"MTEwOTAyMzU1MTQ4NDo1YzY3OWViYWM5NDNmZWUwM2UwY2NmMDBiNDQzMTQ0OQ==\", uri=\"/acegi-security-sample-contacts-filter/secure/adminPermission.htm?contactId=4\", response=\"38644211cf9ac3da63ab639807e2baff\", qop=auth, nc=00000004, cnonce=\"2b8d329a8571b99a\"";
+        String[] headerEntries = StringUtils.commaDelimitedListToStringArray(unsplit);
+        Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
+                "=", null);
+
+        assertEquals("\"marissa\"", headerMap.get("username"));
+        assertEquals("\"Contacts Realm\"", headerMap.get("realm"));
+        assertEquals("\"MTEwOTAyMzU1MTQ4NDo1YzY3OWViYWM5NDNmZWUwM2UwY2NmMDBiNDQzMTQ0OQ==\"",
+            headerMap.get("nonce"));
+        assertEquals("\"/acegi-security-sample-contacts-filter/secure/adminPermission.htm?contactId=4\"",
+            headerMap.get("uri"));
+        assertEquals("\"38644211cf9ac3da63ab639807e2baff\"",
+            headerMap.get("response"));
+        assertEquals("auth", headerMap.get("qop"));
+        assertEquals("00000004", headerMap.get("nc"));
+        assertEquals("\"2b8d329a8571b99a\"", headerMap.get("cnonce"));
+        assertEquals(8, headerMap.size());
+    }
+
+    public void testSplitEachArrayElementAndCreateMapReturnsNullIfArrayEmptyOrNull() {
+        assertNull(StringSplitUtils.splitEachArrayElementAndCreateMap(null,
+                "=", "\""));
+        assertNull(StringSplitUtils.splitEachArrayElementAndCreateMap(
+                new String[] {}, "=", "\""));
+    }
+
+    public void testSplitNormalOperation() {
+        String unsplit = "username=\"marissa==\"";
+        assertEquals("username", StringSplitUtils.split(unsplit, "=")[0]);
+        assertEquals("\"marissa==\"", StringSplitUtils.split(unsplit, "=")[1]); // should not remove quotes or extra equals
+    }
+
+    public void testSplitRejectsNullsAndIncorrectLengthStrings() {
+        try {
+            StringSplitUtils.split(null, "="); // null
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertTrue(true);
+        }
+
+        try {
+            StringSplitUtils.split("", "="); // empty string
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertTrue(true);
+        }
+
+        try {
+            StringSplitUtils.split("sdch=dfgf", null); // null
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertTrue(true);
+        }
+
+        try {
+            StringSplitUtils.split("fvfv=dcdc", ""); // empty string
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertTrue(true);
+        }
+
+        try {
+            StringSplitUtils.split("dfdc=dcdc", "BIGGER_THAN_ONE_CHARACTER");
+            fail("Should have thrown IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertTrue(true);
+        }
+    }
+}

+ 61 - 0
core/src/test/resources/org/acegisecurity/ui/digestauth/filtertest-valid.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">
+<!--
+ * Copyright 2004 Acegi Technology Pty Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * $Id$
+-->
+
+<beans>
+
+	<!-- Data access object which stores authentication information -->
+	<bean id="inMemoryDaoImpl" class="net.sf.acegisecurity.providers.dao.memory.InMemoryDaoImpl">
+  		<property name="userMap">
+			<value>
+				marissa=koala,ROLE_TELLER,ROLE_SUPERVISOR
+				dianne=emu,ROLE_TELLER
+				scott=wombat,ROLE_TELLER
+				peter=opal,disabled,ROLE_TELLER
+			</value>
+		</property>
+	</bean>
+	
+	<!-- Authentication provider that queries our data access object  -->
+	<bean id="daoAuthenticationProvider" class="net.sf.acegisecurity.providers.dao.DaoAuthenticationProvider">
+     	<property name="authenticationDao"><ref bean="inMemoryDaoImpl"/></property>
+	</bean>
+
+	<!-- The authentication manager that iterates through our only authentication provider -->
+	<bean id="authenticationManager" class="net.sf.acegisecurity.providers.ProviderManager">
+		<property name="providers">
+		  <list>
+		    <ref bean="daoAuthenticationProvider"/>
+		  </list>
+		</property>
+	</bean>
+
+   <bean id="digestProcessingFilter" class="net.sf.acegisecurity.ui.digestauth.DigestProcessingFilter">
+      <property name="authenticationDao"><ref local="inMemoryDaoImpl"/></property>
+      <property name="authenticationEntryPoint"><ref local="digestProcessingFilterEntryPoint"/></property>
+   </bean>
+
+   <bean id="digestProcessingFilterEntryPoint" class="net.sf.acegisecurity.ui.digestauth.DigestProcessingFilterEntryPoint">
+      <property name="realmName"><value>Contacts Realm via Digest Authentication</value></property>
+      <property name="key"><value>acegi</value></property>
+      <property name="nonceValiditySeconds"><value>1000</value></property>
+   </bean>
+
+</beans>

+ 144 - 33
doc/docbook/acegi.xml

@@ -2406,34 +2406,21 @@ public boolean supports(Class clazz);</programlisting></para>
 
         <para>The Acegi Security System for Spring provides a
         <literal>BasicProcessingFilter</literal> which is capable of
-        processing authentication credentials presented in HTTP headers. This
-        can be used for authenticating calls made by Spring remoting protocols
-        (such as Hessian and Burlap), as well as normal user agents (such as
-        Internet Explorer and Navigator). The standard governing HTTP Basic
-        Authentication is defined by RFC 1945, Section 11, and the
-        <literal>BasicProcessingFilter</literal> conforms with this
-        RFC.</para>
-
-        <para>To implement HTTP Basic Authentication, it is necessary to add
-        the following filter to <literal>web.xml</literal>:</para>
-
-        <para><programlisting>&lt;filter&gt;
-  &lt;filter-name&gt;Acegi HTTP BASIC Authorization Filter&lt;/filter-name&gt;
-  &lt;filter-class&gt;net.sf.acegisecurity.util.FilterToBeanProxy&lt;/filter-class&gt;
-  &lt;init-param&gt;
-    &lt;param-name&gt;targetClass&lt;/param-name&gt;
-    &lt;param-value&gt;net.sf.acegisecurity.ui.basicauth.BasicProcessingFilter&lt;/param-value&gt;
-  &lt;/init-param&gt;
-&lt;/filter&gt;
-
-&lt;filter-mapping&gt;
-  &lt;filter-name&gt;Acegi HTTP BASIC Authorization Filter&lt;/filter-name&gt;
-  &lt;url-pattern&gt;/*&lt;/url-pattern&gt;
-&lt;/filter-mapping&gt;</programlisting></para>
-
-        <para>For a discussion of <literal>FilterToBeanProxy</literal>, please
-        refer to the Filters section. The application context will need to
-        define the <literal>BasicProcessingFilter</literal> and its required
+        processing basic authentication credentials presented in HTTP headers.
+        This can be used for authenticating calls made by Spring remoting
+        protocols (such as Hessian and Burlap), as well as normal user agents
+        (such as Internet Explorer and Navigator). The standard governing HTTP
+        Basic Authentication is defined by RFC 1945, Section 11, and the
+        <literal>BasicProcessingFilter</literal> conforms with this RFC. Basic
+        Authentication is an attractive approach to authentication, because it
+        is very widely deployed in user agents and implementation is extremely
+        simple (it's just a Base64 encoding of the username:password,
+        specified in a HTTP header).</para>
+
+        <para>To implement HTTP Basic Authentication, it is necessary to
+        define <literal>BasicProcessingFilter</literal> in the fitler chain.
+        The application context will need to define the
+        <literal>BasicProcessingFilter</literal> and its required
         collaborator:</para>
 
         <para><programlisting>&lt;bean id="basicProcessingFilter" class="net.sf.acegisecurity.ui.basicauth.BasicProcessingFilter"&gt;
@@ -2461,12 +2448,136 @@ public boolean supports(Class clazz);</programlisting></para>
         only time the filter chain will be interrupted is if authentication
         fails and the <literal>AuthenticationEntryPoint</literal> is called,
         as discussed in the previous paragraph.</para>
+      </sect2>
+
+      <sect2 id="security-ui-http-digest">
+        <title>HTTP Digest Authentication</title>
+
+        <para>The Acegi Security System for Spring provides a
+        <literal>DigestProcessingFilter</literal> which is capable of
+        processing digest authentication credentials presented in HTTP
+        headers. Digest Authentication attempts to solve many of the
+        weakenesses of Basic authentication, specifically by ensuring
+        credentials are never sent in clear text across the wire. Many user
+        agents support Digest Authentication, including FireFox and Internet
+        Explorer. The standard governing HTTP Digest Authentication is defined
+        by RFC 2617, which updates an earlier version of the Digest
+        Authentication standard prescribed by RFC 2069. Most user agents
+        implement RFC 2617. The Acegi Security
+        <literal>DigestProcessingFilter</literal> is compatible with the
+        "<literal>auth</literal>" quality of protection
+        (<literal>qop</literal>) prescribed by RFC 2617, which also provides
+        backward compatibility with RFC 2069. Digest Authentication is a
+        highly attractive option if you need to use unencrypted HTTP (ie no
+        TLS/HTTPS) and wish to maximise security of the authentication
+        process. Indeed Digest Authentication is a mandatory requirement for
+        the WebDAV protocol, as noted by RFC 2518 Section 17.1, so we should
+        expect to see it increasingly deployed and replacing Basic
+        Authentication.</para>
+
+        <para>Digest Authentication is definitely the most secure choice
+        between Form Authentication, Basic Authentication and Digest
+        Authentication, although extra security also means more complex user
+        agent implementations. Central to Digest Authentication is a "nonce".
+        This is a value the server generates. Acegi Security's nonce adopts
+        the following format:</para>
+
+        <para><programlisting>base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
+
+expirationTime:   The date and time when the nonce expires, expressed in milliseconds
+key:              A private key to prevent modification of the nounce token
+</programlisting></para>
+
+        <para>The <literal>DigestProcessingFilterEntryPoint</literal> has a
+        property specifying the <literal>key</literal> used for generating the
+        nonce tokens, along with a <literal>nonceValiditySeconds</literal>
+        property for determining the expiration time (default 300, which
+        equals five minutes). Whilstever the nonce is valid, the digest is
+        computed by concatenating various strings including the username,
+        password, nonce, URI being requested, a client-generated nonce (merely
+        a random value which the user agent generates each request), the realm
+        name etc, then performing an MD5 hash. Both the server and user agent
+        perform this digest computation, resulting in different hash codes if
+        they disagree on an included value (eg password). In the Acegi
+        Security implementation, if the server-generated nonce has merely
+        expired (but the digest was otherwise valid), the
+        <literal>DigestProcessingFilterEntryPoint</literal> will send a
+        <literal>"stale=true"</literal> header. This tells the user agent
+        there is no need to disturb the user (as the password and username etc
+        is correct), but simply to try again using a new nonce.</para>
+
+        <para>An appropriate value for
+        <literal>DigestProcessingFilterEntryPoint</literal>'s
+        <literal>nonceValiditySeconds</literal> parameter will depend on your
+        application. Extremely secure applications should note that an
+        intercepted authentication header can be used to impersonate the
+        principal until the <literal>expirationTime</literal> contained in the
+        nonce is reached. This is the key principle when selecting an
+        appropriate setting, but it would be unusual for immensly secure
+        applications to not be running over TLS/HTTPS in the first
+        instance.</para>
+
+        <para>Because of the more complex implementation of Digest
+        Authentication, there are often user agent issues. For example,
+        Internet Explorer fails to present an "<literal>opaque</literal>"
+        token on subsequent requests in the same session. The Acegi Security
+        filters therefore encapsulate all state information into the
+        "<literal>nonce</literal>" token instead. In our testing, the Acegi
+        Security implementation works reliably with FireFox and Internet
+        Explorer, correctly handling nonce timeouts etc.</para>
+
+        <para>Now that we've reviewed the theory, let's see how to use it. To
+        implement HTTP Digest Authentication, it is necessary to define
+        <literal>DigestProcessingFilter</literal> in the fitler chain. The
+        application context will need to define the
+        <literal>DigestProcessingFilter</literal> and its required
+        collaborators:</para>
+
+        <para><programlisting>&lt;bean id="digestProcessingFilter" class="net.sf.acegisecurity.ui.digestauth.DigestProcessingFilter"&gt;
+  &lt;property name="authenticationDao"&gt;&lt;ref local="jdbcDaoImpl"/&gt;&lt;/property&gt;
+  &lt;property name="authenticationEntryPoint"&gt;&lt;ref local="digestProcessingFilterEntryPoint"/&gt;&lt;/property&gt;
+  &lt;property name="userCache"&gt;&lt;ref local="userCache"/&gt;&lt;/property&gt;
+&lt;/bean&gt;
+
+&lt;bean id="digestProcessingFilterEntryPoint" class="net.sf.acegisecurity.ui.digestauth.DigestProcessingFilterEntryPoint"&gt;
+  &lt;property name="realmName"&gt;&lt;value&gt;Contacts Realm via Digest Authentication&lt;/value&gt;&lt;/property&gt;
+  &lt;property name="key"&gt;&lt;value&gt;acegi&lt;/value&gt;&lt;/property&gt;
+  &lt;property name="nonceValiditySeconds"&gt;&lt;value&gt;10&lt;/value&gt;&lt;/property&gt;
+&lt;/bean&gt;</programlisting></para>
 
-        <para>HTTP Basic Authentication is recommended to be used instead of
-        Container Adapters. It can be used in conjunction with HTTP Form
-        Authentication, as demonstrated in the Contacts sample application.
-        You can also use it instead of HTTP Form Authentication if you
-        wish.</para>
+        <para>The configured <literal>AuthenticationDao</literal> is needed
+        because <literal>DigestProcessingFilter</literal> must have direct
+        access to the clear text password of a user. Digest Authentication
+        will NOT work if you are using encoded passwords ni your DAO. The DAO
+        collaborator, along with the <literal>UserCache</literal>, are
+        typically shared directly with a
+        <literal>DaoAuthenticationProvider</literal>. The
+        <literal>authenticationEntryPoint</literal> property must be
+        <literal>DigestProcessingFilterEntryPoint</literal>, so that
+        <literal>DigestProcessingFilter</literal> can obtain the correct
+        <literal>realmName</literal> and <literal>key</literal> for digest
+        calculations. </para>
+
+        <para>Like <literal>BasicAuthenticationFilter</literal>, if
+        authentication is successful an <literal>Authentication</literal>
+        request token will be placed into the
+        <literal>ContextHolder</literal>. If the authentication event was
+        successful, or authentication was not attempted because the HTTP
+        header did not contain a Digest Authentication request, the filter
+        chain will continue as normal. The only time the filter chain will be
+        interrupted is if authentication fails and the
+        <literal>AuthenticationEntryPoint</literal> is called, as discussed in
+        the previous paragraph.</para>
+
+        <para>Digest Authentication's RFC offers a range of additional
+        features to further increase security. For example, the nonce can be
+        changed on every request. Despite this, the Acegi Security
+        implementation was designed to minimise the complexity of the
+        implementation (and the doubtless user agent incompatibilities that
+        would emerge), and avoid needing to store server-side state. You are
+        invited to review RFC 2617 if you wish to explore these features in
+        more detail. As far as we are aware, the Acegi Security implementation
+        does comply with the minimum standards of this RFC.</para>
       </sect2>
 
       <sect2 id="security-ui-well-known">

+ 1 - 0
doc/xdocs/changes.xml

@@ -26,6 +26,7 @@
   </properties>
   <body>
     <release version="0.8.0" date="CVS">
+      <action dev="benalex" type="add">Added Digest Authentication support (RFC 2617 and RFC 2069)</action>
       <action dev="benalex" type="update">Made ConfigAttributeDefinition and ConfigAttribute Serializable</action>
       <action dev="benalex" type="update">User now accepts blank passwords (null passwords still rejected)</action>
       <action dev="benalex" type="update">FilterToBeanProxy now searches hierarchical bean factories</action>

+ 5 - 0
doc/xdocs/index.html

@@ -82,6 +82,11 @@
         protocols or those web applications that prefer a simple browser pop-up 
         (rather than a form login), Acegi Security can directly process HTTP 
         BASIC authentication requests as per RFC 1945.<BR><BR>
+        <LI><B>Supports HTTP Digest authentication:</B> For greater security than
+        offered by BASIC authentcation, Acegi Security also supports Digest Authentication
+        (which never sends the user's password across the wire). Digest Authentication
+        is widely supported by modern browsers. Acegi Security's implementation complies
+        with both RFC 2617 and RFC 2069.<BR><BR>
         <LI><B>Convenient security taglib:</B> Your JSP files can use our taglib 
         to ensure that protected content like links and messages are only 
         displayed to users holding the appropriate granted authorities. The taglib