View Javadoc

    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     public void afterPropertiesSet() throws Exception {
    124         Assert.notNull(userDetailsService, "An AuthenticationDao is required");
    125         Assert.notNull(authenticationEntryPoint,
    126             "A DigestProcessingFilterEntryPoint is required");
    127     }
    128
    129     public void destroy() {}
    130
    131     public void doFilter(ServletRequest request, ServletResponse response,
    132         FilterChain chain) throws IOException, ServletException {
    133         if (!(request instanceof HttpServletRequest)) {
    134             throw new ServletException("Can only process HttpServletRequest");
    135         }
    136
    137         if (!(response instanceof HttpServletResponse)) {
    138             throw new ServletException("Can only process HttpServletResponse");
    139         }
    140
    141         HttpServletRequest httpRequest = (HttpServletRequest) request;
    142
    143         String header = httpRequest.getHeader("Authorization");
    144
    145         if (logger.isDebugEnabled()) {
    146             logger.debug("Authorization header received from user agent: "
    147                 + header);
    148         }
    149
    150         if ((header != null) && header.startsWith("Digest ")) {
    151             String section212response = header.substring(7);
    152
    153             String[] headerEntries = StringUtils.commaDelimitedListToStringArray(section212response);
    154             Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries,
    155                     "=", "\"");
    156
    157             String username = (String) headerMap.get("username");
    158             String realm = (String) headerMap.get("realm");
    159             String nonce = (String) headerMap.get("nonce");
    160             String uri = (String) headerMap.get("uri");
    161             String responseDigest = (String) headerMap.get("response");
    162             String qop = (String) headerMap.get("qop"); // RFC 2617 extension
    163             String nc = (String) headerMap.get("nc"); // RFC 2617 extension
    164             String cnonce = (String) headerMap.get("cnonce"); // RFC 2617 extension
    165
    166             // Check all required parameters were supplied (ie RFC 2069)
    167             if ((username == null) || (realm == null) || (nonce == null)
    168                 || (uri == null) || (response == null)) {
    169                 if (logger.isDebugEnabled()) {
    170                     logger.debug("extracted username: '" + username
    171                         + "'; realm: '" + username + "'; nonce: '" + username
    172                         + "'; uri: '" + username + "'; response: '" + username
    173                         + "'");
    174                 }
    175
    176                 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                 return;
    183             }
    184
    185             // Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
    186             if ("auth".equals(qop)) {
    187                 if ((nc == null) || (cnonce == null)) {
    188                     if (logger.isDebugEnabled()) {
    189                         logger.debug("extracted nc: '" + nc + "'; cnonce: '"
    190                             + cnonce + "'");
    191                     }
    192
    193                     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                     return;
    200                 }
    201             }
    202
    203             // Check realm name equals what we expected
    204             if (!this.getAuthenticationEntryPoint().getRealmName().equals(realm)) {
    205                 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                 return;
    213             }
    214
    215             // Check nonce was a Base64 encoded (as sent by DigestProcessingFilterEntryPoint)
    216             if (!Base64.isArrayByteBase64(nonce.getBytes())) {
    217                 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                 return;
    224             }
    225
    226             // Decode nonce from Base64
    227             // format of nonce is:
    228             //   base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
    229             String nonceAsPlainText = new String(Base64.decodeBase64(
    230                         nonce.getBytes()));
    231             String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText,
    232                     ":");
    233
    234             if (nonceTokens.length != 2) {
    235                 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                 return;
    242             }
    243
    244             // Extract expiry time from nonce
    245             long nonceExpiryTime;
    246
    247             try {
    248                 nonceExpiryTime = new Long(nonceTokens[0]).longValue();
    249             } catch (NumberFormatException nfe) {
    250                 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                 return;
    257             }
    258
    259             // Check signature of nonce matches this expiry time
    260             String expectedNonceSignature = DigestUtils.md5Hex(nonceExpiryTime
    261                     + ":" + this.getAuthenticationEntryPoint().getKey());
    262
    263             if (!expectedNonceSignature.equals(nonceTokens[1])) {
    264                 fail(request, response,
    265                     new BadCredentialsException(messages.getMessage(
    266                             "DigestProcessingFilter.nonceCompromised",
    267                             new Object[] {nonceAsPlainText},
    268                             "Nonce token compromised {0}")));
    269
    270                 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             boolean loadedFromDao = false;
    277             UserDetails user = userCache.getUserFromCache(username);
    278
    279             if (user == null) {
    280                 loadedFromDao = true;
    281
    282                 try {
    283                     user = userDetailsService.loadUserByUsername(username);
    284                 } catch (UsernameNotFoundException notFound) {
    285                     fail(request, response,
    286                         new BadCredentialsException(messages.getMessage(
    287                                 "DigestProcessingFilter.usernameNotFound",
    288                                 new Object[] {username},
    289                                 "Username {0} not found")));
    290
    291                     return;
    292                 }
    293
    294                 if (user == null) {
    295                     throw new AuthenticationServiceException(
    296                         "AuthenticationDao returned null, which is an interface contract violation");
    297                 }
    298
    299                 userCache.putUserInCache(user);
    300             }
    301
    302             // Compute the expected response-digest (will be in hex form)
    303             String serverDigestMd5;
    304
    305             // Don't catch IllegalArgumentException (already checked validity)
    306             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             if (!serverDigestMd5.equals(responseDigest) && !loadedFromDao) {
    313                 if (logger.isDebugEnabled()) {
    314                     logger.debug(
    315                         "Digest comparison failure; trying to refresh user from DAO in case password had changed");
    316                 }
    317
    318                 try {
    319                     user = userDetailsService.loadUserByUsername(username);
    320                 } catch (UsernameNotFoundException notFound) {
    321                     // Would very rarely happen, as user existed earlier
    322                     fail(request, response,
    323                         new BadCredentialsException(messages.getMessage(
    324                                 "DigestProcessingFilter.usernameNotFound",
    325                                 new Object[] {username},
    326                                 "Username {0} not found")));
    327                 }
    328
    329                 userCache.putUserInCache(user);
    330
    331                 // Don't catch IllegalArgumentException (already checked validity)
    332                 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             if (!serverDigestMd5.equals(responseDigest)) {
    340                 if (logger.isDebugEnabled()) {
    341                     logger.debug("Expected response: '" + serverDigestMd5
    342                         + "' but received: '" + responseDigest
    343                         + "'; is AuthenticationDao returning clear text passwords?");
    344                 }
    345
    346                 fail(request, response,
    347                     new BadCredentialsException(messages.getMessage(
    348                             "DigestProcessingFilter.incorrectResponse",
    349                             "Incorrect response")));
    350
    351                 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             if (nonceExpiryTime < System.currentTimeMillis()) {
    359                 fail(request, response,
    360                     new NonceExpiredException(messages.getMessage(
    361                             "DigestProcessingFilter.nonceExpired",
    362                             "Nonce has expired/timed out")));
    363
    364                 return;
    365             }
    366
    367             if (logger.isDebugEnabled()) {
    368                 logger.debug("Authentication success for user: '" + username
    369                     + "' with response: '" + responseDigest + "'");
    370             }
    371
    372             UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(user,
    373                     user.getPassword());
    374             authRequest.setDetails(new WebAuthenticationDetails(httpRequest));
    375
    376             SecurityContextHolder.getContext().setAuthentication(authRequest);
    377         }
    378
    379         chain.doFilter(request, response);
    380     }
    381
    382     public static String encodePasswordInA1Format(String username,
    383         String realm, String password) {
    384         String a1 = username + ":" + realm + ":" + password;
    385         String a1Md5 = new String(DigestUtils.md5Hex(a1));
    386
    387         return a1Md5;
    388     }
    389
    390     private void fail(ServletRequest request, ServletResponse response,
    391         AuthenticationException failed) throws IOException, ServletException {
    392         SecurityContextHolder.getContext().setAuthentication(null);
    393
    394         if (logger.isDebugEnabled()) {
    395             logger.debug(failed);
    396         }
    397
    398         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     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         String a1Md5 = null;
    427         String a2 = httpMethod + ":" + uri;
    428         String a2Md5 = new String(DigestUtils.md5Hex(a2));
    429
    430         if (passwordAlreadyEncoded) {
    431             a1Md5 = password;
    432         } else {
    433             a1Md5 = encodePasswordInA1Format(username, realm, password);
    434         }
    435
    436         String digest;
    437
    438         if (qop == null) {
    439             // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
    440             digest = a1Md5 + ":" + nonce + ":" + a2Md5;
    441         } else if ("auth".equals(qop)) {
    442             // As per RFC 2617 compliant clients
    443             digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop
    444                 + ":" + a2Md5;
    445         } else {
    446             throw new IllegalArgumentException(
    447                 "This method does not support a qop: '" + qop + "'");
    448         }
    449
    450         String digestMd5 = new String(DigestUtils.md5Hex(digest));
    451
    452         return digestMd5;
    453     }
    454
    455     public UserDetailsService getUserDetailsService() {
    456         return userDetailsService;
    457     }
    458
    459     public DigestProcessingFilterEntryPoint getAuthenticationEntryPoint() {
    460         return authenticationEntryPoint;
    461     }
    462
    463     public UserCache getUserCache() {
    464         return userCache;
    465     }
    466
    467     public void init(FilterConfig ignored) throws ServletException {}
    468
    469     public void setUserDetailsService(UserDetailsService authenticationDao) {
    470         this.userDetailsService = authenticationDao;
    471     }
    472
    473     public void setAuthenticationEntryPoint(
    474         DigestProcessingFilterEntryPoint authenticationEntryPoint) {
    475         this.authenticationEntryPoint = authenticationEntryPoint;
    476     }
    477
    478     public void setMessageSource(MessageSource messageSource) {
    479         this.messages = new MessageSourceAccessor(messageSource);
    480     }
    481
    482     public void setPasswordAlreadyEncoded(boolean passwordAlreadyEncoded) {
    483         this.passwordAlreadyEncoded = passwordAlreadyEncoded;
    484     }
    485
    486     public void setUserCache(UserCache userCache) {
    487         this.userCache = userCache;
    488     }
    489 }