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