PageRenderTime 53ms CodeModel.GetById 22ms RepoModel.GetById 1ms app.codeStats 0ms

/modules/core/src/main/java/org/adroitlogic/ultraesb/transport/http/auth/DigestProcessingFilter.java

https://bitbucket.org/adroitlogic/ultraesb/
Java | 513 lines | 337 code | 89 blank | 87 comment | 79 complexity | 3c734a9113992ccb7cbff0a368c91829 MD5 | raw file
Possible License(s): AGPL-3.0
  1. /*
  2. * AdroitLogic UltraESB Enterprise Service Bus
  3. *
  4. * Copyright (c) 2010-2015 AdroitLogic Private Ltd. (http://adroitlogic.org). All Rights Reserved.
  5. *
  6. * GNU Affero General Public License Usage
  7. *
  8. * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
  9. * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
  10. * any later version.
  11. *
  12. * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
  13. * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
  14. * more details.
  15. *
  16. * You should have received a copy of the GNU Affero General Public License along with this program (See LICENSE-AGPL.TXT).
  17. * If not, see http://www.gnu.org/licenses/agpl-3.0.html
  18. *
  19. * Commercial Usage
  20. *
  21. * Licensees holding valid UltraESB Commercial licenses may use this file in accordance with the UltraESB Commercial
  22. * License Agreement provided with the Software or, alternatively, in accordance with the terms contained in a written
  23. * agreement between you and AdroitLogic.
  24. *
  25. * If you are unsure which license is appropriate for your use, or have questions regarding the use of this file,
  26. * please contact AdroitLogic at info@adroitlogic.com
  27. */
  28. package org.adroitlogic.ultraesb.transport.http.auth;
  29. import org.adroitlogic.logging.api.Logger;
  30. import org.adroitlogic.logging.api.LoggerFactory;
  31. import org.adroitlogic.ultraesb.api.Message;
  32. import org.adroitlogic.ultraesb.api.transport.http.HttpConstants;
  33. import org.adroitlogic.ultraesb.transport.http.RequestFilter;
  34. import org.adroitlogic.ultraesb.transport.http.custom.listener.UltraAsyncResponseProducer;
  35. import org.apache.commons.codec.binary.Base64;
  36. import org.apache.commons.codec.digest.DigestUtils;
  37. import org.apache.http.Header;
  38. import org.apache.http.HttpRequest;
  39. import org.apache.http.HttpResponse;
  40. import org.apache.http.HttpStatus;
  41. import org.apache.http.nio.protocol.HttpAsyncExchange;
  42. import org.apache.http.protocol.HttpContext;
  43. import org.springframework.beans.factory.InitializingBean;
  44. import org.springframework.context.support.MessageSourceAccessor;
  45. import org.springframework.security.authentication.AuthenticationServiceException;
  46. import org.springframework.security.authentication.BadCredentialsException;
  47. import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
  48. import org.springframework.security.core.AuthenticationException;
  49. import org.springframework.security.core.GrantedAuthority;
  50. import org.springframework.security.core.SpringSecurityMessageSource;
  51. import org.springframework.security.core.context.SecurityContextHolder;
  52. import org.springframework.security.core.userdetails.UserCache;
  53. import org.springframework.security.core.userdetails.UserDetails;
  54. import org.springframework.security.core.userdetails.UserDetailsService;
  55. import org.springframework.security.core.userdetails.UsernameNotFoundException;
  56. import org.springframework.security.core.userdetails.cache.NullUserCache;
  57. import org.springframework.security.web.authentication.www.NonceExpiredException;
  58. import org.springframework.util.StringUtils;
  59. import java.io.IOException;
  60. import java.util.*;
  61. /**
  62. * @author asankha
  63. */
  64. public class DigestProcessingFilter implements RequestFilter, InitializingBean {
  65. private static final Logger logger = LoggerFactory.getLogger(BasicAuthenticationFilter.class);
  66. private static final String[] EMPTY_STRING_ARRAY = new String[0];
  67. private String realmName;
  68. private int nonceValiditySeconds = 300;
  69. private String key;
  70. private boolean passwordAlreadyEncoded = false;
  71. private boolean ignoreFailure = false;
  72. private UserCache userCache = new NullUserCache();
  73. private UserDetailsService userDetailsService;
  74. protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
  75. @Override
  76. public boolean doFilter(HttpAsyncExchange trigger, HttpContext context, Message message) throws IOException {
  77. final HttpRequest req = trigger.getRequest();
  78. final HttpResponse res = trigger.getResponse();
  79. String header = null;
  80. Header h = req.getFirstHeader(HttpConstants.Headers.AUTHORIZATION);
  81. if (h != null) {
  82. header = h.getValue();
  83. }
  84. logger.debug("Authorization header received from user agent : {}", header);
  85. if ((header != null) && header.startsWith("Digest ")) {
  86. String section212response = header.substring(7);
  87. String[] headerEntries = splitIgnoringQuotes(section212response, ',');
  88. Map<String, String> headerMap = splitEachArrayElementAndCreateMap(headerEntries, "=", "\"");
  89. String username = headerMap.get("username");
  90. String realm = headerMap.get("realm");
  91. String nonce = headerMap.get("nonce");
  92. String uri = headerMap.get("uri");
  93. String responseDigest = headerMap.get("response");
  94. String qop = headerMap.get("qop"); // RFC 2617 extension
  95. String nc = headerMap.get("nc"); // RFC 2617 extension
  96. String cnonce = headerMap.get("cnonce"); // RFC 2617 extension
  97. // Check all required parameters were supplied (ie RFC 2069)
  98. if ((username == null) || (realm == null) || (nonce == null) || (uri == null) || (res == null)) {
  99. if (logger.isDebugEnabled()) {
  100. logger.debug("extracted username: '" + username + "'; realm: '" + username + "'; nonce: '"
  101. + nonce + "'; uri: '" + uri + "'; responseDigest: '" + responseDigest + "'");
  102. }
  103. return fail(req, res, trigger,
  104. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingMandatory",
  105. new Object[]{section212response}, "Missing mandatory digest value; received header {0}")), context);
  106. }
  107. // Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
  108. if ("auth".equals(qop)) {
  109. if ((nc == null) || (cnonce == null)) {
  110. logger.debug("extracted nc: {} cnonce: {}", nc, cnonce);
  111. return fail(req, res, trigger,
  112. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingAuth",
  113. new Object[]{section212response}, "Missing mandatory digest value; received header {0}")), context);
  114. }
  115. }
  116. // Check realm name equals what we expected
  117. if (!realmName.equals(realm)) {
  118. return fail(req, res, trigger,
  119. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectRealm",
  120. new Object[]{realm, realmName},
  121. "Response realm name '{0}' does not match system realm name of '{1}'")), context);
  122. }
  123. // Check nonce was a Base64 encoded (as sent by DigestProcessingFilterEntryPoint)
  124. if (!Base64.isBase64(nonce.getBytes())) {
  125. return fail(req, res, trigger,
  126. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceEncoding",
  127. new Object[]{nonce}, "Nonce is not encoded in Base64; received nonce {0}")), context);
  128. }
  129. // Decode nonce from Base64
  130. // format of nonce is:
  131. // base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
  132. String nonceAsPlainText = new String(Base64.decodeBase64(nonce.getBytes()));
  133. String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
  134. if (nonceTokens.length != 2) {
  135. return fail(req, res, trigger,
  136. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceNotTwoTokens",
  137. new Object[]{nonceAsPlainText}, "Nonce should have yielded two tokens but was {0}")), context);
  138. }
  139. // Extract expiry time from nonce
  140. long nonceExpiryTime;
  141. try {
  142. nonceExpiryTime = new Long(nonceTokens[0]);
  143. } catch (NumberFormatException nfe) {
  144. return fail(req, res, trigger,
  145. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceNotNumeric",
  146. new Object[]{nonceAsPlainText},
  147. "Nonce token should have yielded a numeric first token, but was {0}")), context);
  148. }
  149. // Check signature of nonce matches this expiry time
  150. String expectedNonceSignature = DigestUtils.md5Hex(nonceExpiryTime + ":" + key);
  151. if (!expectedNonceSignature.equals(nonceTokens[1])) {
  152. return fail(req, res, trigger,
  153. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceCompromised",
  154. new Object[]{nonceAsPlainText}, "Nonce token compromised {0}")), context);
  155. }
  156. // Lookup password for presented username
  157. // NB: DAO-provided password MUST be clear text - not encoded/salted
  158. // (unless this instance's passwordAlreadyEncoded property is 'false')
  159. boolean loadedFromDao = false;
  160. UserDetails user = userCache.getUserFromCache(username);
  161. if (user == null) {
  162. loadedFromDao = true;
  163. try {
  164. user = userDetailsService.loadUserByUsername(username);
  165. } catch (UsernameNotFoundException notFound) {
  166. return fail(req, res, trigger,
  167. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
  168. new Object[]{username}, "Username {0} not found")), context);
  169. }
  170. if (user == null) {
  171. throw new AuthenticationServiceException(
  172. "AuthenticationDao returned null, which is an interface contract violation");
  173. }
  174. userCache.putUserInCache(user);
  175. }
  176. // Compute the expected res-digest (will be in hex form)
  177. String serverDigestMd5;
  178. // Don't catch IllegalArgumentException (already checked validity)
  179. serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username, realm, user.getPassword(),
  180. req.getRequestLine().getMethod(), uri, qop, nonce, nc, cnonce);
  181. // If digest is incorrect, try refreshing from backend and recomputing
  182. if (!serverDigestMd5.equals(responseDigest) && !loadedFromDao) {
  183. logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
  184. try {
  185. user = userDetailsService.loadUserByUsername(username);
  186. } catch (UsernameNotFoundException notFound) {
  187. // Would very rarely happen, as user existed earlier
  188. fail(req, res, trigger,
  189. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
  190. new Object[]{username}, "Username {0} not found")), context);
  191. }
  192. userCache.putUserInCache(user);
  193. // Don't catch IllegalArgumentException (already checked validity)
  194. serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username, realm, user.getPassword(),
  195. req.getRequestLine().getMethod(), uri, qop, nonce, nc, cnonce);
  196. }
  197. // If digest is still incorrect, definitely reject authentication attempt
  198. if (!serverDigestMd5.equals(responseDigest)) {
  199. logger.debug("Expected res: {} but received: {}; is AuthenticationDao returning clear text passwords?",
  200. serverDigestMd5, responseDigest);
  201. return fail(req, res, trigger,
  202. new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectResponse",
  203. "Incorrect res")), context);
  204. }
  205. // To get this far, the digest must have been valid
  206. // Check the nonce has not expired
  207. // We do this last so we can direct the user agent its nonce is stale
  208. // but the req was otherwise appearing to be valid
  209. if (nonceExpiryTime < System.currentTimeMillis()) {
  210. return fail(req, res, trigger,
  211. new NonceExpiredException(messages.getMessage("DigestProcessingFilter.nonceExpired",
  212. "Nonce has expired/timed out")), context);
  213. }
  214. logger.debug("Authentication successful for user: {} with res: {}", username, responseDigest);
  215. UsernamePasswordAuthenticationToken authRequest =
  216. new UsernamePasswordAuthenticationToken(user, user.getPassword());
  217. SecurityContextHolder.getContext().setAuthentication(authRequest);
  218. Collection auths = user.getAuthorities();
  219. String[] roles = new String[auths.size()];
  220. int i = 0;
  221. for (Object o : auths) {
  222. roles[i++] = ((GrantedAuthority) o).getAuthority();
  223. }
  224. message.addMessageProperty(HttpConstants.MessageProperties.USERROLES_ARRAY, roles);
  225. message.addMessageProperty(HttpConstants.MessageProperties.USERROLES, Arrays.toString(roles));
  226. message.addMessageProperty(HttpConstants.MessageProperties.USERNAME, username);
  227. message.removeTransportHeader(HttpConstants.Headers.AUTHORIZATION);
  228. } else {
  229. return ignoreFailure || commenceAuthentication(req, res, trigger, null);
  230. }
  231. return true;
  232. }
  233. public static String encodePasswordInA1Format(String username, String realm, String password) {
  234. String a1 = username + ":" + realm + ":" + password;
  235. return DigestUtils.md5Hex(a1);
  236. }
  237. private boolean fail(HttpRequest request, HttpResponse response, HttpAsyncExchange trigger,
  238. AuthenticationException failed, HttpContext context) throws IOException {
  239. SecurityContextHolder.getContext().setAuthentication(null);
  240. logger.debug("Authentication failed", failed);
  241. Object remoteAddr = context.getAttribute(HttpConstants.SessionCtx.REMOTE_ADDRESS);
  242. if (remoteAddr != null) {
  243. logger.warn("Digest Authentication attempt failed for IP : " + remoteAddr);
  244. } else {
  245. logger.error("Digest Authentication attempt failed - unable to report remote IP address");
  246. }
  247. return commenceAuthentication(request, response, trigger, failed);
  248. }
  249. public boolean commenceAuthentication(HttpRequest req, HttpResponse res, HttpAsyncExchange trigger,
  250. AuthenticationException authException) throws IOException {
  251. // compute a nonce (do not use remote IP address due to proxy farms)
  252. // format of nonce is:
  253. // base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
  254. long expiryTime = System.currentTimeMillis() + (nonceValiditySeconds * 1000);
  255. String signatureValue = DigestUtils.md5Hex(expiryTime + ":" + key);
  256. String nonceValue = expiryTime + ":" + signatureValue;
  257. String nonceValueBase64 = new String(Base64.encodeBase64(nonceValue.getBytes()));
  258. // qop is quality of protection, as defined by RFC 2617.
  259. // we do not use opaque due to IE violation of RFC 2617 in not
  260. // representing opaque on subsequent requests in same session.
  261. String authenticateHeader = "Digest realm=\"" + realmName + "\", " + "qop=\"auth\", nonce=\""
  262. + nonceValueBase64 + "\"";
  263. if (authException instanceof NonceExpiredException) {
  264. authenticateHeader = authenticateHeader + ", stale=\"true\"";
  265. }
  266. logger.debug("WWW-Authenticate header sent to user agent: {}", authenticateHeader);
  267. if (res != null) {
  268. res.addHeader("WWW-Authenticate", authenticateHeader);
  269. if (authException != null) {
  270. res.setStatusLine(req.getProtocolVersion(), HttpStatus.SC_UNAUTHORIZED, authException.getMessage());
  271. } else {
  272. res.setStatusCode(HttpStatus.SC_UNAUTHORIZED);
  273. }
  274. trigger.submitResponse(new UltraAsyncResponseProducer(res));
  275. }
  276. return false;
  277. }
  278. /**
  279. * Computes the <code>response</code> portion of a Digest authentication header. Both the server and user
  280. * agent should compute the <code>response</code> independently. Provided as a static method to simplify the
  281. * coding of user agents.
  282. *
  283. * @param passwordAlreadyEncoded true if the password argument is already encoded in the correct format. False if
  284. * it is plain text.
  285. * @param username the user's login name.
  286. * @param realm the name of the realm.
  287. * @param password the user's password in plaintext or ready-encoded.
  288. * @param httpMethod the HTTP request method (GET, POST etc.)
  289. * @param uri the request URI.
  290. * @param qop the qop directive, or null if not set.
  291. * @param nonce the nonce supplied by the server
  292. * @param nc the "nonce-count" as defined in RFC 2617.
  293. * @param cnonce opaque string supplied by the client when qop is set.
  294. * @return the MD5 of the digest authentication response, encoded in hex
  295. * @throws IllegalArgumentException if the supplied qop value is unsupported.
  296. */
  297. public static String generateDigest(boolean passwordAlreadyEncoded, String username, String realm, String password,
  298. String httpMethod, String uri, String qop, String nonce, String nc, String cnonce) throws IllegalArgumentException {
  299. String a1Md5;
  300. String a2 = httpMethod + ":" + uri;
  301. String a2Md5 = DigestUtils.md5Hex(a2);
  302. if (passwordAlreadyEncoded) {
  303. a1Md5 = password;
  304. } else {
  305. a1Md5 = encodePasswordInA1Format(username, realm, password);
  306. }
  307. String digest;
  308. if (qop == null) {
  309. // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
  310. digest = a1Md5 + ":" + nonce + ":" + a2Md5;
  311. } else if ("auth".equals(qop)) {
  312. // As per RFC 2617 compliant clients
  313. digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2Md5;
  314. } else {
  315. throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'");
  316. }
  317. return DigestUtils.md5Hex(digest);
  318. }
  319. // ---- setter methods ----
  320. public void setRealmName(String realmName) {
  321. this.realmName = realmName;
  322. }
  323. public void setNonceValiditySeconds(int nonceValiditySeconds) {
  324. this.nonceValiditySeconds = nonceValiditySeconds;
  325. }
  326. public void setKey(String key) {
  327. this.key = key;
  328. }
  329. public void setPasswordAlreadyEncoded(boolean passwordAlreadyEncoded) {
  330. this.passwordAlreadyEncoded = passwordAlreadyEncoded;
  331. }
  332. public void setIgnoreFailure(boolean ignoreFailure) {
  333. this.ignoreFailure = ignoreFailure;
  334. }
  335. // code imported from Spring
  336. static String[] splitIgnoringQuotes(String str, char separatorChar) {
  337. if (str == null) {
  338. return null;
  339. }
  340. int len = str.length();
  341. if (len == 0) {
  342. return EMPTY_STRING_ARRAY;
  343. }
  344. List<String> list = new ArrayList<>();
  345. int i = 0;
  346. int start = 0;
  347. boolean match = false;
  348. while (i < len) {
  349. if (str.charAt(i) == '"') {
  350. i++;
  351. while (i < len) {
  352. if (str.charAt(i) == '"') {
  353. i++;
  354. break;
  355. }
  356. i++;
  357. }
  358. match = true;
  359. continue;
  360. }
  361. if (str.charAt(i) == separatorChar) {
  362. if (match) {
  363. list.add(str.substring(start, i));
  364. match = false;
  365. }
  366. start = ++i;
  367. continue;
  368. }
  369. match = true;
  370. i++;
  371. }
  372. if (match) {
  373. list.add(str.substring(start, i));
  374. }
  375. return list.toArray(new String[list.size()]);
  376. }
  377. static Map<String, String> splitEachArrayElementAndCreateMap(String[] array, String delimiter, String removeCharacters) {
  378. if ((array == null) || (array.length == 0)) {
  379. return Collections.emptyMap();
  380. }
  381. Map<String, String> map = new HashMap<>();
  382. for (String anArray : array) {
  383. String postRemove;
  384. if (removeCharacters == null) {
  385. postRemove = anArray;
  386. } else {
  387. postRemove = StringUtils.replace(anArray, removeCharacters, "");
  388. }
  389. String[] splitThisArrayElement = split(postRemove, delimiter);
  390. if (splitThisArrayElement == null) {
  391. continue;
  392. }
  393. map.put(splitThisArrayElement[0].trim(), splitThisArrayElement[1].trim());
  394. }
  395. return map;
  396. }
  397. static String[] split(String toSplit, String delimiter) {
  398. //Assert.hasLength(toSplit, "Cannot split a null or empty string");
  399. //Assert.hasLength(delimiter, "Cannot use a null or empty delimiter to split a string");
  400. if (delimiter.length() != 1) {
  401. throw new IllegalArgumentException("Delimiter can only be one character in length");
  402. }
  403. int offset = toSplit.indexOf(delimiter);
  404. if (offset < 0) {
  405. return null;
  406. }
  407. String beforeDelimiter = toSplit.substring(0, offset);
  408. String afterDelimiter = toSplit.substring(offset + 1);
  409. return new String[]{beforeDelimiter, afterDelimiter};
  410. }
  411. /**
  412. * Reference to the UserDetailsService to validate credentials
  413. * @param userDetailsService the reference to the UserDetailsService
  414. */
  415. public void setUserDetailsService(UserDetailsService userDetailsService) {
  416. this.userDetailsService = userDetailsService;
  417. }
  418. @Override
  419. public void afterPropertiesSet() throws Exception {
  420. if (userDetailsService == null) {
  421. throw new IllegalArgumentException("The DigestProcessingFilter requires the 'userDetailsService' property");
  422. }
  423. }
  424. }