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 }