/modules/core/src/main/java/org/adroitlogic/ultraesb/transport/http/auth/DigestProcessingFilter.java
Java | 513 lines | 337 code | 89 blank | 87 comment | 79 complexity | 3c734a9113992ccb7cbff0a368c91829 MD5 | raw file
Possible License(s): AGPL-3.0
- /*
- * AdroitLogic UltraESB Enterprise Service Bus
- *
- * Copyright (c) 2010-2015 AdroitLogic Private Ltd. (http://adroitlogic.org). All Rights Reserved.
- *
- * GNU Affero General Public License Usage
- *
- * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
- * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
- * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
- * more details.
- *
- * You should have received a copy of the GNU Affero General Public License along with this program (See LICENSE-AGPL.TXT).
- * If not, see http://www.gnu.org/licenses/agpl-3.0.html
- *
- * Commercial Usage
- *
- * Licensees holding valid UltraESB Commercial licenses may use this file in accordance with the UltraESB Commercial
- * License Agreement provided with the Software or, alternatively, in accordance with the terms contained in a written
- * agreement between you and AdroitLogic.
- *
- * If you are unsure which license is appropriate for your use, or have questions regarding the use of this file,
- * please contact AdroitLogic at info@adroitlogic.com
- */
- package org.adroitlogic.ultraesb.transport.http.auth;
- import org.adroitlogic.logging.api.Logger;
- import org.adroitlogic.logging.api.LoggerFactory;
- import org.adroitlogic.ultraesb.api.Message;
- import org.adroitlogic.ultraesb.api.transport.http.HttpConstants;
- import org.adroitlogic.ultraesb.transport.http.RequestFilter;
- import org.adroitlogic.ultraesb.transport.http.custom.listener.UltraAsyncResponseProducer;
- import org.apache.commons.codec.binary.Base64;
- import org.apache.commons.codec.digest.DigestUtils;
- import org.apache.http.Header;
- import org.apache.http.HttpRequest;
- import org.apache.http.HttpResponse;
- import org.apache.http.HttpStatus;
- import org.apache.http.nio.protocol.HttpAsyncExchange;
- import org.apache.http.protocol.HttpContext;
- import org.springframework.beans.factory.InitializingBean;
- import org.springframework.context.support.MessageSourceAccessor;
- import org.springframework.security.authentication.AuthenticationServiceException;
- import org.springframework.security.authentication.BadCredentialsException;
- import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
- import org.springframework.security.core.AuthenticationException;
- import org.springframework.security.core.GrantedAuthority;
- import org.springframework.security.core.SpringSecurityMessageSource;
- import org.springframework.security.core.context.SecurityContextHolder;
- import org.springframework.security.core.userdetails.UserCache;
- import org.springframework.security.core.userdetails.UserDetails;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.core.userdetails.UsernameNotFoundException;
- import org.springframework.security.core.userdetails.cache.NullUserCache;
- import org.springframework.security.web.authentication.www.NonceExpiredException;
- import org.springframework.util.StringUtils;
- import java.io.IOException;
- import java.util.*;
- /**
- * @author asankha
- */
- public class DigestProcessingFilter implements RequestFilter, InitializingBean {
- private static final Logger logger = LoggerFactory.getLogger(BasicAuthenticationFilter.class);
- private static final String[] EMPTY_STRING_ARRAY = new String[0];
- private String realmName;
- private int nonceValiditySeconds = 300;
- private String key;
- private boolean passwordAlreadyEncoded = false;
- private boolean ignoreFailure = false;
- private UserCache userCache = new NullUserCache();
- private UserDetailsService userDetailsService;
- protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
- @Override
- public boolean doFilter(HttpAsyncExchange trigger, HttpContext context, Message message) throws IOException {
- final HttpRequest req = trigger.getRequest();
- final HttpResponse res = trigger.getResponse();
- String header = null;
- Header h = req.getFirstHeader(HttpConstants.Headers.AUTHORIZATION);
- if (h != null) {
- header = h.getValue();
- }
- logger.debug("Authorization header received from user agent : {}", header);
- if ((header != null) && header.startsWith("Digest ")) {
- String section212response = header.substring(7);
- String[] headerEntries = splitIgnoringQuotes(section212response, ',');
- Map<String, String> headerMap = splitEachArrayElementAndCreateMap(headerEntries, "=", "\"");
- String username = headerMap.get("username");
- String realm = headerMap.get("realm");
- String nonce = headerMap.get("nonce");
- String uri = headerMap.get("uri");
- String responseDigest = headerMap.get("response");
- String qop = headerMap.get("qop"); // RFC 2617 extension
- String nc = headerMap.get("nc"); // RFC 2617 extension
- String cnonce = headerMap.get("cnonce"); // RFC 2617 extension
- // Check all required parameters were supplied (ie RFC 2069)
- if ((username == null) || (realm == null) || (nonce == null) || (uri == null) || (res == null)) {
- if (logger.isDebugEnabled()) {
- logger.debug("extracted username: '" + username + "'; realm: '" + username + "'; nonce: '"
- + nonce + "'; uri: '" + uri + "'; responseDigest: '" + responseDigest + "'");
- }
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingMandatory",
- new Object[]{section212response}, "Missing mandatory digest value; received header {0}")), context);
- }
- // Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
- if ("auth".equals(qop)) {
- if ((nc == null) || (cnonce == null)) {
- logger.debug("extracted nc: {} cnonce: {}", nc, cnonce);
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.missingAuth",
- new Object[]{section212response}, "Missing mandatory digest value; received header {0}")), context);
- }
- }
- // Check realm name equals what we expected
- if (!realmName.equals(realm)) {
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectRealm",
- new Object[]{realm, realmName},
- "Response realm name '{0}' does not match system realm name of '{1}'")), context);
- }
- // Check nonce was a Base64 encoded (as sent by DigestProcessingFilterEntryPoint)
- if (!Base64.isBase64(nonce.getBytes())) {
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceEncoding",
- new Object[]{nonce}, "Nonce is not encoded in Base64; received nonce {0}")), context);
- }
- // Decode nonce from Base64
- // format of nonce is:
- // base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
- String nonceAsPlainText = new String(Base64.decodeBase64(nonce.getBytes()));
- String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
- if (nonceTokens.length != 2) {
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceNotTwoTokens",
- new Object[]{nonceAsPlainText}, "Nonce should have yielded two tokens but was {0}")), context);
- }
- // Extract expiry time from nonce
- long nonceExpiryTime;
- try {
- nonceExpiryTime = new Long(nonceTokens[0]);
- } catch (NumberFormatException nfe) {
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceNotNumeric",
- new Object[]{nonceAsPlainText},
- "Nonce token should have yielded a numeric first token, but was {0}")), context);
- }
- // Check signature of nonce matches this expiry time
- String expectedNonceSignature = DigestUtils.md5Hex(nonceExpiryTime + ":" + key);
- if (!expectedNonceSignature.equals(nonceTokens[1])) {
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.nonceCompromised",
- new Object[]{nonceAsPlainText}, "Nonce token compromised {0}")), context);
- }
- // Lookup password for presented username
- // NB: DAO-provided password MUST be clear text - not encoded/salted
- // (unless this instance's passwordAlreadyEncoded property is 'false')
- boolean loadedFromDao = false;
- UserDetails user = userCache.getUserFromCache(username);
- if (user == null) {
- loadedFromDao = true;
- try {
- user = userDetailsService.loadUserByUsername(username);
- } catch (UsernameNotFoundException notFound) {
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
- new Object[]{username}, "Username {0} not found")), context);
- }
- if (user == null) {
- throw new AuthenticationServiceException(
- "AuthenticationDao returned null, which is an interface contract violation");
- }
- userCache.putUserInCache(user);
- }
- // Compute the expected res-digest (will be in hex form)
- String serverDigestMd5;
- // Don't catch IllegalArgumentException (already checked validity)
- serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username, realm, user.getPassword(),
- req.getRequestLine().getMethod(), uri, qop, nonce, nc, cnonce);
- // If digest is incorrect, try refreshing from backend and recomputing
- if (!serverDigestMd5.equals(responseDigest) && !loadedFromDao) {
- logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
- try {
- user = userDetailsService.loadUserByUsername(username);
- } catch (UsernameNotFoundException notFound) {
- // Would very rarely happen, as user existed earlier
- fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.usernameNotFound",
- new Object[]{username}, "Username {0} not found")), context);
- }
- userCache.putUserInCache(user);
- // Don't catch IllegalArgumentException (already checked validity)
- serverDigestMd5 = generateDigest(passwordAlreadyEncoded, username, realm, user.getPassword(),
- req.getRequestLine().getMethod(), uri, qop, nonce, nc, cnonce);
- }
- // If digest is still incorrect, definitely reject authentication attempt
- if (!serverDigestMd5.equals(responseDigest)) {
- logger.debug("Expected res: {} but received: {}; is AuthenticationDao returning clear text passwords?",
- serverDigestMd5, responseDigest);
- return fail(req, res, trigger,
- new BadCredentialsException(messages.getMessage("DigestProcessingFilter.incorrectResponse",
- "Incorrect res")), context);
- }
- // To get this far, the digest must have been valid
- // Check the nonce has not expired
- // We do this last so we can direct the user agent its nonce is stale
- // but the req was otherwise appearing to be valid
- if (nonceExpiryTime < System.currentTimeMillis()) {
- return fail(req, res, trigger,
- new NonceExpiredException(messages.getMessage("DigestProcessingFilter.nonceExpired",
- "Nonce has expired/timed out")), context);
- }
- logger.debug("Authentication successful for user: {} with res: {}", username, responseDigest);
- UsernamePasswordAuthenticationToken authRequest =
- new UsernamePasswordAuthenticationToken(user, user.getPassword());
- SecurityContextHolder.getContext().setAuthentication(authRequest);
- Collection auths = user.getAuthorities();
- String[] roles = new String[auths.size()];
- int i = 0;
- for (Object o : auths) {
- roles[i++] = ((GrantedAuthority) o).getAuthority();
- }
- message.addMessageProperty(HttpConstants.MessageProperties.USERROLES_ARRAY, roles);
- message.addMessageProperty(HttpConstants.MessageProperties.USERROLES, Arrays.toString(roles));
- message.addMessageProperty(HttpConstants.MessageProperties.USERNAME, username);
- message.removeTransportHeader(HttpConstants.Headers.AUTHORIZATION);
- } else {
- return ignoreFailure || commenceAuthentication(req, res, trigger, null);
- }
- return true;
- }
- public static String encodePasswordInA1Format(String username, String realm, String password) {
- String a1 = username + ":" + realm + ":" + password;
- return DigestUtils.md5Hex(a1);
- }
- private boolean fail(HttpRequest request, HttpResponse response, HttpAsyncExchange trigger,
- AuthenticationException failed, HttpContext context) throws IOException {
- SecurityContextHolder.getContext().setAuthentication(null);
- logger.debug("Authentication failed", failed);
- Object remoteAddr = context.getAttribute(HttpConstants.SessionCtx.REMOTE_ADDRESS);
- if (remoteAddr != null) {
- logger.warn("Digest Authentication attempt failed for IP : " + remoteAddr);
- } else {
- logger.error("Digest Authentication attempt failed - unable to report remote IP address");
- }
- return commenceAuthentication(request, response, trigger, failed);
- }
- public boolean commenceAuthentication(HttpRequest req, HttpResponse res, HttpAsyncExchange trigger,
- AuthenticationException authException) throws IOException {
- // compute a nonce (do not use remote IP address due to proxy farms)
- // format of nonce is:
- // base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
- long expiryTime = System.currentTimeMillis() + (nonceValiditySeconds * 1000);
- String signatureValue = DigestUtils.md5Hex(expiryTime + ":" + key);
- String nonceValue = expiryTime + ":" + signatureValue;
- String nonceValueBase64 = new String(Base64.encodeBase64(nonceValue.getBytes()));
- // qop is quality of protection, as defined by RFC 2617.
- // we do not use opaque due to IE violation of RFC 2617 in not
- // representing opaque on subsequent requests in same session.
- String authenticateHeader = "Digest realm=\"" + realmName + "\", " + "qop=\"auth\", nonce=\""
- + nonceValueBase64 + "\"";
- if (authException instanceof NonceExpiredException) {
- authenticateHeader = authenticateHeader + ", stale=\"true\"";
- }
- logger.debug("WWW-Authenticate header sent to user agent: {}", authenticateHeader);
- if (res != null) {
- res.addHeader("WWW-Authenticate", authenticateHeader);
- if (authException != null) {
- res.setStatusLine(req.getProtocolVersion(), HttpStatus.SC_UNAUTHORIZED, authException.getMessage());
- } else {
- res.setStatusCode(HttpStatus.SC_UNAUTHORIZED);
- }
- trigger.submitResponse(new UltraAsyncResponseProducer(res));
- }
- return false;
- }
- /**
- * Computes the <code>response</code> portion of a Digest authentication header. Both the server and user
- * agent should compute the <code>response</code> independently. Provided as a static method to simplify the
- * coding of user agents.
- *
- * @param passwordAlreadyEncoded true if the password argument is already encoded in the correct format. False if
- * it is plain text.
- * @param username the user's login name.
- * @param realm the name of the realm.
- * @param password the user's password in plaintext or ready-encoded.
- * @param httpMethod the HTTP request method (GET, POST etc.)
- * @param uri the request URI.
- * @param qop the qop directive, or null if not set.
- * @param nonce the nonce supplied by the server
- * @param nc the "nonce-count" as defined in RFC 2617.
- * @param cnonce opaque string supplied by the client when qop is set.
- * @return the MD5 of the digest authentication response, encoded in hex
- * @throws IllegalArgumentException if the supplied qop value is unsupported.
- */
- public static String generateDigest(boolean passwordAlreadyEncoded, String username, String realm, String password,
- String httpMethod, String uri, String qop, String nonce, String nc, String cnonce) throws IllegalArgumentException {
- String a1Md5;
- String a2 = httpMethod + ":" + uri;
- String a2Md5 = DigestUtils.md5Hex(a2);
- if (passwordAlreadyEncoded) {
- a1Md5 = password;
- } else {
- a1Md5 = encodePasswordInA1Format(username, realm, password);
- }
- String digest;
- if (qop == null) {
- // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617)
- digest = a1Md5 + ":" + nonce + ":" + a2Md5;
- } else if ("auth".equals(qop)) {
- // As per RFC 2617 compliant clients
- digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2Md5;
- } else {
- throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'");
- }
- return DigestUtils.md5Hex(digest);
- }
- // ---- setter methods ----
- public void setRealmName(String realmName) {
- this.realmName = realmName;
- }
- public void setNonceValiditySeconds(int nonceValiditySeconds) {
- this.nonceValiditySeconds = nonceValiditySeconds;
- }
- public void setKey(String key) {
- this.key = key;
- }
- public void setPasswordAlreadyEncoded(boolean passwordAlreadyEncoded) {
- this.passwordAlreadyEncoded = passwordAlreadyEncoded;
- }
- public void setIgnoreFailure(boolean ignoreFailure) {
- this.ignoreFailure = ignoreFailure;
- }
- // code imported from Spring
- static String[] splitIgnoringQuotes(String str, char separatorChar) {
- if (str == null) {
- return null;
- }
- int len = str.length();
- if (len == 0) {
- return EMPTY_STRING_ARRAY;
- }
- List<String> list = new ArrayList<>();
- int i = 0;
- int start = 0;
- boolean match = false;
- while (i < len) {
- if (str.charAt(i) == '"') {
- i++;
- while (i < len) {
- if (str.charAt(i) == '"') {
- i++;
- break;
- }
- i++;
- }
- match = true;
- continue;
- }
- if (str.charAt(i) == separatorChar) {
- if (match) {
- list.add(str.substring(start, i));
- match = false;
- }
- start = ++i;
- continue;
- }
- match = true;
- i++;
- }
- if (match) {
- list.add(str.substring(start, i));
- }
- return list.toArray(new String[list.size()]);
- }
- static Map<String, String> splitEachArrayElementAndCreateMap(String[] array, String delimiter, String removeCharacters) {
- if ((array == null) || (array.length == 0)) {
- return Collections.emptyMap();
- }
- Map<String, String> map = new HashMap<>();
- for (String anArray : array) {
- String postRemove;
- if (removeCharacters == null) {
- postRemove = anArray;
- } else {
- postRemove = StringUtils.replace(anArray, removeCharacters, "");
- }
- String[] splitThisArrayElement = split(postRemove, delimiter);
- if (splitThisArrayElement == null) {
- continue;
- }
- map.put(splitThisArrayElement[0].trim(), splitThisArrayElement[1].trim());
- }
- return map;
- }
- static String[] split(String toSplit, String delimiter) {
- //Assert.hasLength(toSplit, "Cannot split a null or empty string");
- //Assert.hasLength(delimiter, "Cannot use a null or empty delimiter to split a string");
- if (delimiter.length() != 1) {
- throw new IllegalArgumentException("Delimiter can only be one character in length");
- }
- int offset = toSplit.indexOf(delimiter);
- if (offset < 0) {
- return null;
- }
- String beforeDelimiter = toSplit.substring(0, offset);
- String afterDelimiter = toSplit.substring(offset + 1);
- return new String[]{beforeDelimiter, afterDelimiter};
- }
- /**
- * Reference to the UserDetailsService to validate credentials
- * @param userDetailsService the reference to the UserDetailsService
- */
- public void setUserDetailsService(UserDetailsService userDetailsService) {
- this.userDetailsService = userDetailsService;
- }
- @Override
- public void afterPropertiesSet() throws Exception {
- if (userDetailsService == null) {
- throw new IllegalArgumentException("The DigestProcessingFilter requires the 'userDetailsService' property");
- }
- }
- }