View Javadoc

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  public class DigestProcessingFilter implements Filter, InitializingBean, MessageSourceAware {
88      //~ Static fields/initializers =====================================================================================
89  
90      private static final Log logger = LogFactory.getLog(DigestProcessingFilter.class);
91  
92      //~ Instance fields ================================================================================================
93  
94      private AuthenticationDetailsSource authenticationDetailsSource = new AuthenticationDetailsSourceImpl();
95      private DigestProcessingFilterEntryPoint authenticationEntryPoint;
96      protected MessageSourceAccessor messages = AcegiMessageSource.getAccessor();
97      private UserCache userCache = new NullUserCache();
98      private UserDetailsService userDetailsService;
99      private boolean passwordAlreadyEncoded = false;
100 
101     //~ Methods ========================================================================================================
102 
103     public void afterPropertiesSet() throws Exception {
104         Assert.notNull(userDetailsService, "A UserDetailsService is required");
105         Assert.notNull(authenticationEntryPoint, "A DigestProcessingFilterEntryPoint is required");
106     }
107 
108     public void destroy() {
109     }
110 
111     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
112             throws IOException, ServletException {
113         if (!(request instanceof HttpServletRequest)) {
114             throw new ServletException("Can only process HttpServletRequest");
115         }
116 
117         if (!(response instanceof HttpServletResponse)) {
118             throw new ServletException("Can only process HttpServletResponse");
119         }
120 
121         HttpServletRequest httpRequest = (HttpServletRequest) request;
122 
123         String header = httpRequest.getHeader("Authorization");
124 
125         if (logger.isDebugEnabled()) {
126             logger.debug("Authorization header received from user agent: " + header);
127         }
128 
129         if ((header != null) && header.startsWith("Digest ")) {
130             String section212response = header.substring(7);
131 
132             String[] headerEntries = StringSplitUtils.splitIgnoringQuotes(section212response, ',');
133             Map headerMap = StringSplitUtils.splitEachArrayElementAndCreateMap(headerEntries, "=", "\"");
134 
135             String username = (String) headerMap.get("username");
136             String realm = (String) headerMap.get("realm");
137             String nonce = (String) headerMap.get("nonce");
138             String uri = (String) headerMap.get("uri");
139             String responseDigest = (String) headerMap.get("response");
140             String qop = (String) headerMap.get("qop"); // RFC 2617 extension
141             String nc = (String) headerMap.get("nc"); // RFC 2617 extension
142             String cnonce = (String) headerMap.get("cnonce"); // RFC 2617 extension
143 
144             // Check all required parameters were supplied (ie RFC 2069)
145             if ((username == null) || (realm == null) || (nonce == null) || (uri == null) || (response == null)) {
146                 if (logger.isDebugEnabled()) {
147                     logger.debug("extracted username: '" + username + "'; realm: '" + username + "'; nonce: '"
148                             + username + "'; uri: '" + username + "'; response: '" + username + "'");
149                 }
150 
151                 fail(request, response,
152                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingMandatory",
153                                 new Object[]{section212response}, "Missing mandatory digest value; received header {0}")));
154 
155                 return;
156             }
157 
158             // Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
159             if ("auth".equals(qop)) {
160                 if ((nc == null) || (cnonce == null)) {
161                     if (logger.isDebugEnabled()) {
162                         logger.debug("extracted nc: '" + nc + "'; cnonce: '" + cnonce + "'");
163                     }
164 
165                     fail(request, response,
166                             new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingAuth",
167                                     new Object[]{section212response}, "Missing mandatory digest value; received header {0}")));
168 
169                     return;
170                 }
171             }
172 
173             // Check realm name equals what we expected
174             if (!this.getAuthenticationEntryPoint().getRealmName().equals(realm)) {
175                 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                 return;
181             }
182 
183             // Check nonce was a Base64 encoded (as sent by DigestProcessingFilterEntryPoint)
184             if (!Base64.isArrayByteBase64(nonce.getBytes())) {
185                 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                 return;
190             }
191 
192             // Decode nonce from Base64
193             // format of nonce is:
194             //   base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
195             String nonceAsPlainText = new String(Base64.decodeBase64(nonce.getBytes()));
196             String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
197 
198             if (nonceTokens.length != 2) {
199                 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                 return;
204             }
205 
206             // Extract expiry time from nonce
207             long nonceExpiryTime;
208 
209             try {
210                 nonceExpiryTime = new Long(nonceTokens[0]).longValue();
211             } catch (NumberFormatException nfe) {
212                 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                 return;
218             }
219 
220             // Check signature of nonce matches this expiry time
221             String expectedNonceSignature = DigestUtils.md5Hex(nonceExpiryTime + ":"
222                     + this.getAuthenticationEntryPoint().getKey());
223 
224             if (!expectedNonceSignature.equals(nonceTokens[1])) {
225                 fail(request, response,
226                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceCompromised",
227                                 new Object[]{nonceAsPlainText}, "Nonce token compromised {0}")));
228 
229                 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             boolean loadedFromDao = false;
236             UserDetails user = userCache.getUserFromCache(username);
237 
238             if (user == null) {
239                 loadedFromDao = true;
240 
241                 try {
242                     user = userDetailsService.loadUserByUsername(username);
243                 } catch (UsernameNotFoundException notFound) {
244                     fail(request, response,
245                             new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
246                                     new Object[]{username}, "Username {0} not found")));
247 
248                     return;
249                 }
250 
251                 if (user == null) {
252                     throw new AuthenticationServiceException(
253                             "AuthenticationDao returned null, which is an interface contract violation");
254                 }
255 
256                 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             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             if (!serverDigestMd5.equals(responseDigest) && !loadedFromDao) {
268                 if (logger.isDebugEnabled()) {
269                     logger.debug(
270                             "Digest comparison failure; trying to refresh user from DAO in case password had changed");
271                 }
272 
273                 try {
274                     user = userDetailsService.loadUserByUsername(username);
275                 } catch (UsernameNotFoundException notFound) {
276                     // Would very rarely happen, as user existed earlier
277                     fail(request, response,
278                             new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
279                                     new Object[]{username}, "Username {0} not found")));
280                 }
281 
282                 userCache.putUserInCache(user);
283 
284                 // Don't catch IllegalArgumentException (already checked validity)
285                 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             if (!serverDigestMd5.equals(responseDigest)) {
291                 if (logger.isDebugEnabled()) {
292                     logger.debug("Expected response: '" + serverDigestMd5 + "' but received: '" + responseDigest
293                             + "'; is AuthenticationDao returning clear text passwords?");
294                 }
295 
296                 fail(request, response,
297                         new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectResponse",
298                                 "Incorrect response")));
299 
300                 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             if (nonceExpiryTime < System.currentTimeMillis()) {
308                 fail(request, response,
309                         new NonceExpiredException(messages.getMessage("DigestProcessingFilter.nonceExpired",
310                                 "Nonce has expired/timed out")));
311 
312                 return;
313             }
314 
315             if (logger.isDebugEnabled()) {
316                 logger.debug("Authentication success for user: '" + username + "' with response: '" + responseDigest
317                         + "'");
318             }
319 
320             UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(user,
321                     user.getPassword());
322 
323             authRequest.setDetails(authenticationDetailsSource.buildDetails((HttpServletRequest) request));
324 
325             SecurityContextHolder.getContext().setAuthentication(authRequest);
326         }
327 
328         chain.doFilter(request, response);
329     }
330 
331     public static String encodePasswordInA1Format(String username, String realm, String password) {
332         String a1 = username + ":" + realm + ":" + password;
333         String a1Md5 = new String(DigestUtils.md5Hex(a1));
334 
335         return a1Md5;
336     }
337 
338     private void fail(ServletRequest request, ServletResponse response, AuthenticationException failed)
339             throws IOException, ServletException {
340         SecurityContextHolder.getContext().setAuthentication(null);
341 
342         if (logger.isDebugEnabled()) {
343             logger.debug(failed);
344         }
345 
346         authenticationEntryPoint.commence(request, response, failed);
347     }
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         String a1Md5 = null;
372         String a2 = httpMethod + ":" + uri;
373         String a2Md5 = new String(DigestUtils.md5Hex(a2));
374 
375         if (passwordAlreadyEncoded) {
376             a1Md5 = password;
377         } else {
378             a1Md5 = encodePasswordInA1Format(username, realm, password);
379         }
380 
381         String digest;
382 
383         if (qop == null) {
384             // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
385             digest = a1Md5 + ":" + nonce + ":" + a2Md5;
386         } else if ("auth".equals(qop)) {
387             // As per RFC 2617 compliant clients
388             digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2Md5;
389         } else {
390             throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'");
391         }
392 
393         String digestMd5 = new String(DigestUtils.md5Hex(digest));
394 
395         return digestMd5;
396     }
397 
398     public DigestProcessingFilterEntryPoint getAuthenticationEntryPoint() {
399         return authenticationEntryPoint;
400     }
401 
402     public UserCache getUserCache() {
403         return userCache;
404     }
405 
406     public UserDetailsService getUserDetailsService() {
407         return userDetailsService;
408     }
409 
410     public void init(FilterConfig ignored) throws ServletException {
411     }
412 
413     public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) {
414         Assert.notNull(authenticationDetailsSource, "AuthenticationDetailsSource required");
415         this.authenticationDetailsSource = authenticationDetailsSource;
416     }
417 
418     public void setAuthenticationEntryPoint(DigestProcessingFilterEntryPoint authenticationEntryPoint) {
419         this.authenticationEntryPoint = authenticationEntryPoint;
420     }
421 
422     public void setMessageSource(MessageSource messageSource) {
423         this.messages = new MessageSourceAccessor(messageSource);
424     }
425 
426     public void setPasswordAlreadyEncoded(boolean passwordAlreadyEncoded) {
427         this.passwordAlreadyEncoded = passwordAlreadyEncoded;
428     }
429 
430     public void setUserCache(UserCache userCache) {
431         this.userCache = userCache;
432     }
433 
434     public void setUserDetailsService(UserDetailsService userDetailsService) {
435         this.userDetailsService = userDetailsService;
436     }
437 }