1
2
3
4
5
6
7
8
9
10
11
12
13
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
110
111 private static final Log logger = LogFactory.getLog(DigestProcessingFilter.class);
112
113
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
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");
163 String nc = (String) headerMap.get("nc");
164 String cnonce = (String) headerMap.get("cnonce");
165
166
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
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
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
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
227
228
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
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
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
274
275
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
303 String serverDigestMd5;
304
305
306 serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username,
307 realm, user.getPassword(),
308 ((HttpServletRequest) request).getMethod(), uri, qop,
309 nonce, nc, cnonce);
310
311
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
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
332 serverDigestMd5 = generateDigest(passwordAlreadyEncoded,
333 username, realm, user.getPassword(),
334 ((HttpServletRequest) request).getMethod(), uri, qop,
335 nonce, nc, cnonce);
336 }
337
338
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
355
356
357
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
440 digest = a1Md5 + ":" + nonce + ":" + a2Md5;
441 } else if ("auth".equals(qop)) {
442
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 }