Coverage Report - org.acegisecurity.ui.digestauth.DigestProcessingFilter
 
Classes in this File Line Coverage Branch Coverage Complexity
DigestProcessingFilter
77% 
92% 
3.625
 
 1  
 /* Copyright 2004, 2005, 2006 Acegi Technology Pty Limited
 2  
  *
 3  
  * Licensed under the Apache License, Version 2.0 (the "License");
 4  
  * you may not use this file except in compliance with the License.
 5  
  * You may obtain a copy of the License at
 6  
  *
 7  
  *     http://www.apache.org/licenses/LICENSE-2.0
 8  
  *
 9  
  * Unless required by applicable law or agreed to in writing, software
 10  
  * distributed under the License is distributed on an "AS IS" BASIS,
 11  
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12  
  * See the License for the specific language governing permissions and
 13  
  * limitations under the License.
 14  
  */
 15  
 
 16  
 package org.acegisecurity.ui.digestauth;
 17  
 
 18  
 import org.acegisecurity.AcegiMessageSource;
 19  
 import org.acegisecurity.AuthenticationException;
 20  
 import org.acegisecurity.AuthenticationServiceException;
 21  
 import org.acegisecurity.BadCredentialsException;
 22  
 
 23  
 import org.acegisecurity.context.SecurityContextHolder;
 24  
 
 25  
 import org.acegisecurity.providers.UsernamePasswordAuthenticationToken;
 26  
 import org.acegisecurity.providers.dao.UserCache;
 27  
 import org.acegisecurity.providers.dao.cache.NullUserCache;
 28  
 
 29  
 import org.acegisecurity.ui.AuthenticationDetailsSource;
 30  
 import org.acegisecurity.ui.AuthenticationDetailsSourceImpl;
 31  
 
 32  
 import org.acegisecurity.userdetails.UserDetails;
 33  
 import org.acegisecurity.userdetails.UserDetailsService;
 34  
 import org.acegisecurity.userdetails.UsernameNotFoundException;
 35  
 
 36  
 import org.acegisecurity.util.StringSplitUtils;
 37  
 
 38  
 import org.apache.commons.codec.binary.Base64;
 39  
 import org.apache.commons.codec.digest.DigestUtils;
 40  
 import org.apache.commons.logging.Log;
 41  
 import org.apache.commons.logging.LogFactory;
 42  
 
 43  
 import org.springframework.beans.factory.InitializingBean;
 44  
 
 45  
 import org.springframework.context.MessageSource;
 46  
 import org.springframework.context.MessageSourceAware;
 47  
 import org.springframework.context.support.MessageSourceAccessor;
 48  
 
 49  
 import org.springframework.util.Assert;
 50  
 import org.springframework.util.StringUtils;
 51  
 
 52  
 import java.io.IOException;
 53  
 
 54  
 import java.util.Map;
 55  
 
 56  
 import javax.servlet.Filter;
 57  
 import javax.servlet.FilterChain;
 58  
 import javax.servlet.FilterConfig;
 59  
 import javax.servlet.ServletException;
 60  
 import javax.servlet.ServletRequest;
 61  
 import javax.servlet.ServletResponse;
 62  
 import javax.servlet.http.HttpServletRequest;
 63  
 import javax.servlet.http.HttpServletResponse;
 64  
 
 65  
 
 66  
 /**
 67  
  * Processes a HTTP request's Digest authorization headers, putting the result into the
 68  
  * <code>SecurityContextHolder</code>.<p>For a detailed background on what this filter is designed to process,
 69  
  * refer to <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a> (which superseded RFC 2069, although this
 70  
  * filter support clients that implement either RFC 2617 or RFC 2069).</p>
 71  
  * <p>This filter can be used to provide Digest authentication services to both remoting protocol clients (such as
 72  
  * Hessian and SOAP) as well as standard user agents (such as Internet Explorer and FireFox).</p>
 73  
  * <p>This Digest implementation has been designed to avoid needing to store session state between invocations.
 74  
  * All session management information is stored in the "nonce" that is sent to the client by the {@link
 75  
  * DigestProcessingFilterEntryPoint}.</p>
 76  
  * <P>If authentication is successful, the resulting {@link org.acegisecurity.Authentication Authentication}
 77  
  * object will be placed into the <code>SecurityContextHolder</code>.</p>
 78  
  * <p>If authentication fails, an {@link org.acegisecurity.ui.AuthenticationEntryPoint AuthenticationEntryPoint}
 79  
  * implementation is called. This must always be {@link DigestProcessingFilterEntryPoint}, which will prompt the user
 80  
  * to authenticate again via Digest authentication.</p>
 81  
  * <p>Note there are limitations to Digest authentication, although it is a more comprehensive and secure solution
 82  
  * than Basic authentication. Please see RFC 2617 section 4 for a full discussion on the advantages of Digest
 83  
  * authentication over Basic authentication, including commentary on the limitations that it still imposes.</p>
 84  
  * <p><b>Do not use this class directly.</b> Instead configure <code>web.xml</code> to use the {@link
 85  
  * org.acegisecurity.util.FilterToBeanProxy}.</p>
 86  
  */
 87  26
 public class DigestProcessingFilter implements Filter, InitializingBean, MessageSourceAware {
 88  
     //~ Static fields/initializers =====================================================================================
 89  
 
 90  2
     private static final Log logger = LogFactory.getLog(DigestProcessingFilter.class);
 91  
 
 92  
     //~ Instance fields ================================================================================================
 93  
 
 94  26
     private AuthenticationDetailsSource authenticationDetailsSource = new AuthenticationDetailsSourceImpl();
 95  
     private DigestProcessingFilterEntryPoint authenticationEntryPoint;
 96  26
     protected MessageSourceAccessor messages = AcegiMessageSource.getAccessor();
 97  26
     private UserCache userCache = new NullUserCache();
 98  
     private UserDetailsService userDetailsService;
 99  26
     private boolean passwordAlreadyEncoded = false;
 100  
 
 101  
     //~ Methods ========================================================================================================
 102  
 
 103  
     public void afterPropertiesSet() throws Exception {
 104  2
         Assert.notNull(userDetailsService, "A UserDetailsService is required");
 105  1
         Assert.notNull(authenticationEntryPoint, "A DigestProcessingFilterEntryPoint is required");
 106  0
     }
 107  
 
 108  
     public void destroy() {
 109  17
     }
 110  
 
 111  
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
 112  
             throws IOException, ServletException {
 113  19
         if (!(request instanceof HttpServletRequest)) {
 114  1
             throw new ServletException("Can only process HttpServletRequest");
 115  
         }
 116  
 
 117  18
         if (!(response instanceof HttpServletResponse)) {
 118  1
             throw new ServletException("Can only process HttpServletResponse");
 119  
         }
 120  
 
 121  17
         HttpServletRequest httpRequest = (HttpServletRequest) request;
 122  
 
 123  17
         String header = httpRequest.getHeader("Authorization");
 124  
 
 125  17
         if (logger.isDebugEnabled()) {
 126  0
             logger.debug("Authorization header received from user agent: " + header);
 127  
         }
 128  
 
 129  17
         if ((header != null) && header.startsWith("Digest ")) {
 130  15
             String section212response = header.substring(7);
 131  
 
 132  15
             String[] headerEntries = StringSplitUtils.splitIgnoringQuotes(section212response, ',');
 133  15
             Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries, "=", "\"");
 134  
 
 135  15
             String username = (String) headerMap.get("username");
 136  15
             String realm = (String) headerMap.get("realm");
 137  15
             String nonce = (String) headerMap.get("nonce");
 138  15
             String uri = (String) headerMap.get("uri");
 139  15
             String responseDigest = (String) headerMap.get("response");
 140  15
             String qop = (String) headerMap.get("qop"); // RFC 2617 extension
 141  15
             String nc = (String) headerMap.get("nc"); // RFC 2617 extension
 142  15
             String cnonce = (String) headerMap.get("cnonce"); // RFC 2617 extension
 143  
 
 144  
             // Check all required parameters were supplied (ie RFC 2069)
 145  15
             if ((username == null) || (realm == null) || (nonce == null) || (uri == null) || (response == null)) {
 146  2
                 if (logger.isDebugEnabled()) {
 147  0
                     logger.debug("extracted username: '" + username + "'; realm: '" + username + "'; nonce: '"
 148  
                             + username + "'; uri: '" + username + "'; response: '" + username + "'");
 149  
                 }
 150  
 
 151  2
                 fail(request, response,
 152  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingMandatory",
 153  
                                 new Object[]{section212response}, "Missing mandatory digest value; received header {0}")));
 154  
 
 155  2
                 return;
 156  
             }
 157  
 
 158  
             // Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
 159  13
             if ("auth".equals(qop)) {
 160  13
                 if ((nc == null) || (cnonce == null)) {
 161  0
                     if (logger.isDebugEnabled()) {
 162  0
                         logger.debug("extracted nc: '" + nc + "'; cnonce: '" + cnonce + "'");
 163  
                     }
 164  
 
 165  0
                     fail(request, response,
 166  
                             new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingAuth",
 167  
                                     new Object[]{section212response}, "Missing mandatory digest value; received header {0}")));
 168  
 
 169  0
                     return;
 170  
                 }
 171  
             }
 172  
 
 173  
             // Check realm name equals what we expected
 174  13
             if (!this.getAuthenticationEntryPoint().getRealmName().equals(realm)) {
 175  1
                 fail(request, response,
 176  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectRealm",
 177  
                                 new Object[]{realm, this.getAuthenticationEntryPoint().getRealmName()},
 178  
                                 "Response realm name '{0}' does not match system realm name of '{1}'")));
 179  
 
 180  1
                 return;
 181  
             }
 182  
 
 183  
             // Check nonce was a Base64 encoded (as sent by DigestProcessingFilterEntryPoint)
 184  12
             if (!Base64.isArrayByteBase64(nonce.getBytes())) {
 185  1
                 fail(request, response,
 186  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceEncoding",
 187  
                                 new Object[]{nonce}, "Nonce is not encoded in Base64; received nonce {0}")));
 188  
 
 189  1
                 return;
 190  
             }
 191  
 
 192  
             // Decode nonce from Base64
 193  
             // format of nonce is:
 194  
             //   base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
 195  11
             String nonceAsPlainText = new String(Base64.decodeBase64(nonce.getBytes()));
 196  11
             String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
 197  
 
 198  11
             if (nonceTokens.length != 2) {
 199  1
                 fail(request, response,
 200  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceNotTwoTokens",
 201  
                                 new Object[]{nonceAsPlainText}, "Nonce should have yielded two tokens but was {0}")));
 202  
 
 203  1
                 return;
 204  
             }
 205  
 
 206  
             // Extract expiry time from nonce
 207  
             long nonceExpiryTime;
 208  
 
 209  
             try {
 210  10
                 nonceExpiryTime = new Long(nonceTokens[0]).longValue();
 211  1
             } catch (NumberFormatException nfe) {
 212  1
                 fail(request, response,
 213  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceNotNumeric",
 214  
                                 new Object[]{nonceAsPlainText},
 215  
                                 "Nonce token should have yielded a numeric first token, but was {0}")));
 216  
 
 217  1
                 return;
 218  9
             }
 219  
 
 220  
             // Check signature of nonce matches this expiry time
 221  9
             String expectedNonceSignature = DigestUtils.md5Hex(nonceExpiryTime + ":"
 222  
                     + this.getAuthenticationEntryPoint().getKey());
 223  
 
 224  9
             if (!expectedNonceSignature.equals(nonceTokens[1])) {
 225  1
                 fail(request, response,
 226  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceCompromised",
 227  
                                 new Object[]{nonceAsPlainText}, "Nonce token compromised {0}")));
 228  
 
 229  1
                 return;
 230  
             }
 231  
 
 232  
             // Lookup password for presented username
 233  
             // NB: DAO-provided password MUST be clear text - not encoded/salted
 234  
             // (unless this instance's passwordAlreadyEncoded property is 'false')
 235  8
             boolean loadedFromDao = false;
 236  8
             UserDetails user = userCache.getUserFromCache(username);
 237  
 
 238  8
             if (user == null) {
 239  8
                 loadedFromDao = true;
 240  
 
 241  
                 try {
 242  8
                     user = userDetailsService.loadUserByUsername(username);
 243  0
                 } catch (UsernameNotFoundException notFound) {
 244  0
                     fail(request, response,
 245  
                             new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
 246  
                                     new Object[]{username}, "Username {0} not found")));
 247  
 
 248  0
                     return;
 249  8
                 }
 250  
 
 251  8
                 if (user == null) {
 252  0
                     throw new AuthenticationServiceException(
 253  
                             "AuthenticationDao returned null, which is an interface contract violation");
 254  
                 }
 255  
 
 256  8
                 userCache.putUserInCache(user);
 257  
             }
 258  
 
 259  
             // Compute the expected response-digest (will be in hex form)
 260  
             String serverDigestMd5;
 261  
 
 262  
             // Don't catch IllegalArgumentException (already checked validity)
 263  8
             serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username, realm, user.getPassword(),
 264  
                     ((HttpServletRequest) request).getMethod(), uri, qop, nonce, nc, cnonce);
 265  
 
 266  
             // If digest is incorrect, try refreshing from backend and recomputing
 267  8
             if (!serverDigestMd5.equals(responseDigest) && !loadedFromDao) {
 268  0
                 if (logger.isDebugEnabled()) {
 269  0
                     logger.debug(
 270  
                             "Digest comparison failure; trying to refresh user from DAO in case password had changed");
 271  
                 }
 272  
 
 273  
                 try {
 274  0
                     user = userDetailsService.loadUserByUsername(username);
 275  0
                 } catch (UsernameNotFoundException notFound) {
 276  
                     // Would very rarely happen, as user existed earlier
 277  0
                     fail(request, response,
 278  
                             new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
 279  
                                     new Object[]{username}, "Username {0} not found")));
 280  0
                 }
 281  
 
 282  0
                 userCache.putUserInCache(user);
 283  
 
 284  
                 // Don't catch IllegalArgumentException (already checked validity)
 285  0
                 serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username, realm, user.getPassword(),
 286  
                         ((HttpServletRequest) request).getMethod(), uri, qop, nonce, nc, cnonce);
 287  
             }
 288  
 
 289  
             // If digest is still incorrect, definitely reject authentication attempt
 290  8
             if (!serverDigestMd5.equals(responseDigest)) {
 291  4
                 if (logger.isDebugEnabled()) {
 292  0
                     logger.debug("Expected response: '" + serverDigestMd5 + "' but received: '" + responseDigest
 293  
                             + "'; is AuthenticationDao returning clear text passwords?");
 294  
                 }
 295  
 
 296  4
                 fail(request, response,
 297  
                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectResponse",
 298  
                                 "Incorrect response")));
 299  
 
 300  4
                 return;
 301  
             }
 302  
 
 303  
             // To get this far, the digest must have been valid
 304  
             // Check the nonce has not expired
 305  
             // We do this last so we can direct the user agent its nonce is stale
 306  
             // but the request was otherwise appearing to be valid
 307  4
             if (nonceExpiryTime < System.currentTimeMillis()) {
 308  1
                 fail(request, response,
 309  
                         new NonceExpiredException(messages.getMessage("DigestProcessingFilter.nonceExpired",
 310  
                                 "Nonce has expired/timed out")));
 311  
 
 312  1
                 return;
 313  
             }
 314  
 
 315  3
             if (logger.isDebugEnabled()) {
 316  0
                 logger.debug("Authentication success for user: '" + username + "' with response: '" + responseDigest
 317  
                         + "'");
 318  
             }
 319  
 
 320  3
             UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(user,
 321  
                     user.getPassword());
 322  
 
 323  3
             authRequest.setDetails(authenticationDetailsSource.buildDetails((HttpServletRequest) request));
 324  
 
 325  3
             SecurityContextHolder.getContext().setAuthentication(authRequest);
 326  
         }
 327  
 
 328  5
         chain.doFilter(request, response);
 329  5
     }
 330  
 
 331  
     public static String encodePasswordInA1Format(String username, String realm, String password) {
 332  21
         String a1 = username + ":" + realm + ":" + password;
 333  21
         String a1Md5 = new String(DigestUtils.md5Hex(a1));
 334  
 
 335  21
         return a1Md5;
 336  
     }
 337  
 
 338  
     private void fail(ServletRequest request, ServletResponse response, AuthenticationException failed)
 339  
             throws IOException, ServletException {
 340  12
         SecurityContextHolder.getContext().setAuthentication(null);
 341  
 
 342  12
         if (logger.isDebugEnabled()) {
 343  0
             logger.debug(failed);
 344  
         }
 345  
 
 346  12
         authenticationEntryPoint.commence(request, response, failed);
 347  12
     }
 348  
 
 349  
     /**
 350  
      * Computes the <code>response</code> portion of a Digest authentication header. Both the server and user
 351  
      * agent should compute the <code>response</code> independently. Provided as a static method to simplify the
 352  
      * coding of user agents.
 353  
      *
 354  
      * @param passwordAlreadyEncoded true if the password argument is already encoded in the correct format. False if
 355  
      *                               it is plain text.
 356  
      * @param username               the user's login name.
 357  
      * @param realm                  the name of the realm.
 358  
      * @param password               the user's password in plaintext or ready-encoded.
 359  
      * @param httpMethod             the HTTP request method (GET, POST etc.)
 360  
      * @param uri                    the request URI.
 361  
      * @param qop                    the qop directive, or null if not set.
 362  
      * @param nonce                  the nonce supplied by the server
 363  
      * @param nc                     the "nonce-count" as defined in RFC 2617.
 364  
      * @param cnonce                 opaque string supplied by the client when qop is set.
 365  
      * @return the MD5 of the digest authentication response, encoded in hex
 366  
      * @throws IllegalArgumentException if the supplied qop value is unsupported.
 367  
      */
 368  
     public static String generateDigest(boolean passwordAlreadyEncoded, String username, String realm, String password,
 369  
                                         String httpMethod, String uri, String qop, String nonce, String nc, String cnonce)
 370  
             throws IllegalArgumentException {
 371  21
         String a1Md5 = null;
 372  21
         String a2 = httpMethod + ":" + uri;
 373  21
         String a2Md5 = new String(DigestUtils.md5Hex(a2));
 374  
 
 375  21
         if (passwordAlreadyEncoded) {
 376  1
             a1Md5 = password;
 377  
         } else {
 378  20
             a1Md5 = encodePasswordInA1Format(username, realm, password);
 379  
         }
 380  
 
 381  
         String digest;
 382  
 
 383  21
         if (qop == null) {
 384  
             // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
 385  0
             digest = a1Md5 + ":" + nonce + ":" + a2Md5;
 386  21
         } else if ("auth".equals(qop)) {
 387  
             // As per RFC 2617 compliant clients
 388  21
             digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2Md5;
 389  
         } else {
 390  0
             throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'");
 391  
         }
 392  
 
 393  21
         String digestMd5 = new String(DigestUtils.md5Hex(digest));
 394  
 
 395  21
         return digestMd5;
 396  
     }
 397  
 
 398  
     public DigestProcessingFilterEntryPoint getAuthenticationEntryPoint() {
 399  24
         return authenticationEntryPoint;
 400  
     }
 401  
 
 402  
     public UserCache getUserCache() {
 403  2
         return userCache;
 404  
     }
 405  
 
 406  
     public UserDetailsService getUserDetailsService() {
 407  1
         return userDetailsService;
 408  
     }
 409  
 
 410  
     public void init(FilterConfig ignored) throws ServletException {
 411  17
     }
 412  
 
 413  
     public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) {
 414  0
         Assert.notNull(authenticationDetailsSource, "AuthenticationDetailsSource required");
 415  0
         this.authenticationDetailsSource = authenticationDetailsSource;
 416  0
     }
 417  
 
 418  
     public void setAuthenticationEntryPoint(DigestProcessingFilterEntryPoint authenticationEntryPoint) {
 419  23
         this.authenticationEntryPoint = authenticationEntryPoint;
 420  23
     }
 421  
 
 422  
     public void setMessageSource(MessageSource messageSource) {
 423  0
         this.messages = new MessageSourceAccessor(messageSource);
 424  0
     }
 425  
 
 426  
     public void setPasswordAlreadyEncoded(boolean passwordAlreadyEncoded) {
 427  0
         this.passwordAlreadyEncoded = passwordAlreadyEncoded;
 428  0
     }
 429  
 
 430  
     public void setUserCache(UserCache userCache) {
 431  2
         this.userCache = userCache;
 432  2
     }
 433  
 
 434  
     public void setUserDetailsService(UserDetailsService userDetailsService) {
 435  23
         this.userDetailsService = userDetailsService;
 436  23
     }
 437  
 }