From a3818184f4e72921d529038b9af51cf7a7da508d Mon Sep 17 00:00:00 2001 From: Ben Alex Date: Tue, 22 Feb 2005 06:14:44 +0000 Subject: [PATCH] Added Digest Authentication support (RFC 2617 and RFC 2069). --- .../ui/basicauth/BasicProcessingFilter.java | 16 +- .../ui/digestauth/DigestProcessingFilter.java | 452 +++++++++ .../DigestProcessingFilterEntryPoint.java | 137 +++ .../ui/digestauth/NonceExpiredException.java | 51 + .../acegisecurity/ui/digestauth/package.html | 5 + .../acegisecurity/util/StringSplitUtils.java | 123 +++ .../acegisecurity/MockHttpServletRequest.java | 2 +- ...DigestProcessingFilterEntryPointTests.java | 172 ++++ .../DigestProcessingFilterTests.java | 887 ++++++++++++++++++ .../util/StringSplitUtilsTests.java | 140 +++ .../ui/digestauth/filtertest-valid.xml | 61 ++ doc/docbook/acegi.xml | 175 +++- doc/xdocs/changes.xml | 1 + doc/xdocs/index.html | 5 + 14 files changed, 2189 insertions(+), 38 deletions(-) create mode 100644 core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilter.java create mode 100644 core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPoint.java create mode 100644 core/src/main/java/org/acegisecurity/ui/digestauth/NonceExpiredException.java create mode 100644 core/src/main/java/org/acegisecurity/ui/digestauth/package.html create mode 100644 core/src/main/java/org/acegisecurity/util/StringSplitUtils.java create mode 100644 core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPointTests.java create mode 100644 core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterTests.java create mode 100644 core/src/test/java/org/acegisecurity/util/StringSplitUtilsTests.java create mode 100644 core/src/test/resources/org/acegisecurity/ui/digestauth/filtertest-valid.xml diff --git a/core/src/main/java/org/acegisecurity/ui/basicauth/BasicProcessingFilter.java b/core/src/main/java/org/acegisecurity/ui/basicauth/BasicProcessingFilter.java index 1f5419448f..8340940bb0 100644 --- a/core/src/main/java/org/acegisecurity/ui/basicauth/BasicProcessingFilter.java +++ b/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 HttpSession. + * into the ContextHolder. * *

* For a detailed background on what this filter is designed to process, refer @@ -75,9 +74,7 @@ import javax.servlet.http.HttpServletResponse; * *

* If authentication is successful, the resulting {@link Authentication} object - * will be placed into the HttpSession with the attribute defined - * by {@link - * HttpSessionContextIntegrationFilter#ACEGI_SECURITY_AUTHENTICATION_KEY}. + * will be placed into the ContextHolder. *

* *

@@ -87,6 +84,15 @@ import javax.servlet.http.HttpServletResponse; *

* *

+ * 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}. + *

+ * + *

* Do not use this class directly. Instead configure * web.xml to use the {@link * net.sf.acegisecurity.util.FilterToBeanProxy}. diff --git a/core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilter.java b/core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilter.java new file mode 100644 index 0000000000..9529b5c5d4 --- /dev/null +++ b/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 ContextHolder. + * + *

+ * For a detailed background on what this filter is designed to process, refer + * to RFC 2617 (which + * superseeded RFC 2069, although this filter support clients that implement + * either RFC 2617 or RFC 2069). + *

+ * + *

+ * 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). + *

+ * + *

+ * 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}. + *

+ * + *

+ * If authentication is successful, the resulting {@link Authentication} object + * will be placed into the ContextHolder. + *

+ * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ * + *

+ * Do not use this class directly. Instead configure + * web.xml to use the {@link + * net.sf.acegisecurity.util.FilterToBeanProxy}. + *

+ * + * @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 response portion of a Digest authentication + * header. Both the server and user agent should compute the + * response 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); + } +} diff --git a/core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPoint.java b/core/src/main/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPoint.java new file mode 100644 index 0000000000..5815ab56b0 --- /dev/null +++ b/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 SecurityEnforcementFilter to commence + * authentication via the {@link DigestProcessingFilter}. + * + *

+ * 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 stale=true 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). + *

+ * + * @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()); + } +} diff --git a/core/src/main/java/org/acegisecurity/ui/digestauth/NonceExpiredException.java b/core/src/main/java/org/acegisecurity/ui/digestauth/NonceExpiredException.java new file mode 100644 index 0000000000..3604733d7a --- /dev/null +++ b/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 NonceExpiredException with the specified + * message. + * + * @param msg the detail message + */ + public NonceExpiredException(String msg) { + super(msg); + } + + /** + * Constructs a NonceExpiredException 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); + } +} diff --git a/core/src/main/java/org/acegisecurity/ui/digestauth/package.html b/core/src/main/java/org/acegisecurity/ui/digestauth/package.html new file mode 100644 index 0000000000..a6ba011cc2 --- /dev/null +++ b/core/src/main/java/org/acegisecurity/ui/digestauth/package.html @@ -0,0 +1,5 @@ + + +Authenticates HTTP Digest authentication requests. + + diff --git a/core/src/main/java/org/acegisecurity/util/StringSplitUtils.java b/core/src/main/java/org/acegisecurity/util/StringSplitUtils.java new file mode 100644 index 0000000000..3588713a33 --- /dev/null +++ b/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 String manipulation methods. + * + * @author Ben Alex + * @version $Id$ + */ +public class StringSplitUtils { + //~ Methods ================================================================ + + /** + * Splits a String at the first instance of the delimiter. + * + *

+ * Does not include the delimiter in the response. + *

+ * + * @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 Strings, and for each element removes any + * instances of removeCharacter, and splits the element based + * on the delimiter. A Map is then generated, + * with the left of the delimiter providing the key, and the right of the + * delimiter providing the value. + * + *

+ * Will trim both the key and value before adding to the Map. + *

+ * + * @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 null if no removal should + * occur + * + * @return a Map representing the array contents, or + * null 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; + } +} diff --git a/core/src/test/java/org/acegisecurity/MockHttpServletRequest.java b/core/src/test/java/org/acegisecurity/MockHttpServletRequest.java index eea99f4cd0..145d98e287 100644 --- a/core/src/test/java/org/acegisecurity/MockHttpServletRequest.java +++ b/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) { diff --git a/core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPointTests.java b/core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterEntryPointTests.java new file mode 100644 index 0000000000..748c4c979b --- /dev/null +++ b/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]); + } +} diff --git a/core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterTests.java b/core/src/test/java/org/acegisecurity/ui/digestauth/DigestProcessingFilterTests.java new file mode 100644 index 0000000000..ddb55f6fe9 --- /dev/null +++ b/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"); + } + } + } +} diff --git a/core/src/test/java/org/acegisecurity/util/StringSplitUtilsTests.java b/core/src/test/java/org/acegisecurity/util/StringSplitUtilsTests.java new file mode 100644 index 0000000000..09fc3df48b --- /dev/null +++ b/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); + } + } +} diff --git a/core/src/test/resources/org/acegisecurity/ui/digestauth/filtertest-valid.xml b/core/src/test/resources/org/acegisecurity/ui/digestauth/filtertest-valid.xml new file mode 100644 index 0000000000..eb1d789d9f --- /dev/null +++ b/core/src/test/resources/org/acegisecurity/ui/digestauth/filtertest-valid.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + marissa=koala,ROLE_TELLER,ROLE_SUPERVISOR + dianne=emu,ROLE_TELLER + scott=wombat,ROLE_TELLER + peter=opal,disabled,ROLE_TELLER + + + + + + + + + + + + + + + + + + + + + + + + + Contacts Realm via Digest Authentication + acegi + 1000 + + + diff --git a/doc/docbook/acegi.xml b/doc/docbook/acegi.xml index a97d9562a8..6b4d9522e8 100644 --- a/doc/docbook/acegi.xml +++ b/doc/docbook/acegi.xml @@ -2406,34 +2406,21 @@ public boolean supports(Class clazz); The Acegi Security System for Spring provides a BasicProcessingFilter 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 - BasicProcessingFilter conforms with this - RFC. + 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 + BasicProcessingFilter 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). - To implement HTTP Basic Authentication, it is necessary to add - the following filter to web.xml: - - <filter> - <filter-name>Acegi HTTP BASIC Authorization Filter</filter-name> - <filter-class>net.sf.acegisecurity.util.FilterToBeanProxy</filter-class> - <init-param> - <param-name>targetClass</param-name> - <param-value>net.sf.acegisecurity.ui.basicauth.BasicProcessingFilter</param-value> - </init-param> -</filter> - -<filter-mapping> - <filter-name>Acegi HTTP BASIC Authorization Filter</filter-name> - <url-pattern>/*</url-pattern> -</filter-mapping> - - For a discussion of FilterToBeanProxy, please - refer to the Filters section. The application context will need to - define the BasicProcessingFilter and its required + To implement HTTP Basic Authentication, it is necessary to + define BasicProcessingFilter in the fitler chain. + The application context will need to define the + BasicProcessingFilter and its required collaborator: <bean id="basicProcessingFilter" class="net.sf.acegisecurity.ui.basicauth.BasicProcessingFilter"> @@ -2461,12 +2448,136 @@ public boolean supports(Class clazz); only time the filter chain will be interrupted is if authentication fails and the AuthenticationEntryPoint is called, as discussed in the previous paragraph. + - 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. + + HTTP Digest Authentication + + The Acegi Security System for Spring provides a + DigestProcessingFilter 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 + DigestProcessingFilter is compatible with the + "auth" quality of protection + (qop) 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. + + 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: + + 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 + + + The DigestProcessingFilterEntryPoint has a + property specifying the key used for generating the + nonce tokens, along with a nonceValiditySeconds + 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 + DigestProcessingFilterEntryPoint will send a + "stale=true" 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. + + An appropriate value for + DigestProcessingFilterEntryPoint's + nonceValiditySeconds 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 expirationTime 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. + + Because of the more complex implementation of Digest + Authentication, there are often user agent issues. For example, + Internet Explorer fails to present an "opaque" + token on subsequent requests in the same session. The Acegi Security + filters therefore encapsulate all state information into the + "nonce" token instead. In our testing, the Acegi + Security implementation works reliably with FireFox and Internet + Explorer, correctly handling nonce timeouts etc. + + Now that we've reviewed the theory, let's see how to use it. To + implement HTTP Digest Authentication, it is necessary to define + DigestProcessingFilter in the fitler chain. The + application context will need to define the + DigestProcessingFilter and its required + collaborators: + + <bean id="digestProcessingFilter" class="net.sf.acegisecurity.ui.digestauth.DigestProcessingFilter"> + <property name="authenticationDao"><ref local="jdbcDaoImpl"/></property> + <property name="authenticationEntryPoint"><ref local="digestProcessingFilterEntryPoint"/></property> + <property name="userCache"><ref local="userCache"/></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>10</value></property> +</bean> + + The configured AuthenticationDao is needed + because DigestProcessingFilter 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 UserCache, are + typically shared directly with a + DaoAuthenticationProvider. The + authenticationEntryPoint property must be + DigestProcessingFilterEntryPoint, so that + DigestProcessingFilter can obtain the correct + realmName and key for digest + calculations. + + Like BasicAuthenticationFilter, if + authentication is successful an Authentication + request token will be placed into the + ContextHolder. 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 + AuthenticationEntryPoint is called, as discussed in + the previous paragraph. + + 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. diff --git a/doc/xdocs/changes.xml b/doc/xdocs/changes.xml index 29ff8f59a4..c5cc15925a 100644 --- a/doc/xdocs/changes.xml +++ b/doc/xdocs/changes.xml @@ -26,6 +26,7 @@ + Added Digest Authentication support (RFC 2617 and RFC 2069) Made ConfigAttributeDefinition and ConfigAttribute Serializable User now accepts blank passwords (null passwords still rejected) FilterToBeanProxy now searches hierarchical bean factories diff --git a/doc/xdocs/index.html b/doc/xdocs/index.html index 50c4e114fe..b5a2523f6d 100644 --- a/doc/xdocs/index.html +++ b/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.

+
  • Supports HTTP Digest authentication: 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.

  • Convenient security taglib: 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