From 639e20992a66d7a42fb59c974db91c8a0f730a1e Mon Sep 17 00:00:00 2001 From: Mark Emlyn David Thomas Date: Fri, 1 Apr 2011 11:36:54 +0000 Subject: [PATCH] Add additional configuration options to the DIGEST authenticator This is the fix for CVE-2011-1184 git-svn-id: https://svn.apache.org/repos/asf/tomcat/trunk@1087655 13f79535-47bb-0310-9956-ffa450edef68 --- .../authenticator/DigestAuthenticator.java | 499 ++++++++++++++---- .../authenticator/LocalStrings.properties | 2 + .../authenticator/mbeans-descriptors.xml | 20 + java/org/apache/catalina/realm/RealmBase.java | 9 +- .../TestDigestAuthenticator.java | 349 ++++++++++++ .../TesterDigestAuthenticatorPerformance.java | 262 +++++++++ webapps/docs/changelog.xml | 4 + webapps/docs/config/valve.xml | 34 ++ 8 files changed, 1068 insertions(+), 111 deletions(-) create mode 100644 test/org/apache/catalina/authenticator/TestDigestAuthenticator.java create mode 100644 test/org/apache/catalina/authenticator/TesterDigestAuthenticatorPerformance.java diff --git a/java/org/apache/catalina/authenticator/DigestAuthenticator.java b/java/org/apache/catalina/authenticator/DigestAuthenticator.java index 3afcf41d0dcb..8f5100c123bf 100644 --- a/java/org/apache/catalina/authenticator/DigestAuthenticator.java +++ b/java/org/apache/catalina/authenticator/DigestAuthenticator.java @@ -23,11 +23,14 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.Principal; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.StringTokenizer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.catalina.LifecycleException; import org.apache.catalina.Realm; import org.apache.catalina.connector.Request; import org.apache.catalina.deploy.LoginConfig; @@ -46,8 +49,8 @@ * @version $Id$ */ -public class DigestAuthenticator - extends AuthenticatorBase { +public class DigestAuthenticator extends AuthenticatorBase { + private static final Log log = LogFactory.getLog(DigestAuthenticator.class); @@ -66,6 +69,11 @@ public class DigestAuthenticator "org.apache.catalina.authenticator.DigestAuthenticator/1.0"; + /** + * Tomcat's DIGEST implementation only supports auth quality of protection. + */ + protected static final String QOP = "auth"; + // ----------------------------------------------------------- Constructors @@ -90,15 +98,46 @@ public DigestAuthenticator() { protected static volatile MessageDigest md5Helper; + /** + * List of client nonce values currently being tracked + */ + protected Map cnonces; + + + /** + * Maximum number of client nonces to keep in the cache. If not specified, + * the default value of 1000 is used. + */ + protected int cnonceCacheSize = 1000; + + /** * Private key. */ - protected String key = "Catalina"; + protected String key = null; - // ------------------------------------------------------------- Properties + /** + * How long server nonces are valid for in milliseconds. Defaults to 5 + * minutes. + */ + protected long nonceValidity = 5 * 60 * 1000; + + + /** + * Opaque string. + */ + protected String opaque; + /** + * Should the URI be validated as required by RFC2617? Can be disabled in + * reverse proxies where the proxy has modified the URI. + */ + protected boolean validateUri = true; + + // ------------------------------------------------------------- Properties + /** * Return descriptive information about this Valve implementation. */ @@ -110,9 +149,58 @@ public String getInfo() { } - // --------------------------------------------------------- Public Methods + public int getCnonceCacheSize() { + return cnonceCacheSize; + } + + + public void setCnonceCacheSize(int cnonceCacheSize) { + this.cnonceCacheSize = cnonceCacheSize; + } + + + public String getKey() { + return key; + } + + + public void setKey(String key) { + this.key = key; + } + + + public long getNonceValidity() { + return nonceValidity; + } + + + public void setNonceValidity(long nonceValidity) { + this.nonceValidity = nonceValidity; + } + + + public String getOpaque() { + return opaque; + } + + + public void setOpaque(String opaque) { + this.opaque = opaque; + } + + + public boolean isValidateUri() { + return validateUri; + } + + + public void setValidateUri(boolean validateUri) { + this.validateUri = validateUri; + } + // --------------------------------------------------------- Public Methods + /** * Authenticate the user making this request, based on the specified * login configuration. Return true if any specified @@ -173,8 +261,13 @@ public boolean authenticate(Request request, // Validate any credentials already included with this request String authorization = request.getHeader("authorization"); + DigestInfo digestInfo = new DigestInfo(getOpaque(), getNonceValidity(), + getKey(), cnonces, isValidateUri()); if (authorization != null) { - principal = findPrincipal(request, authorization, context.getRealm()); + if (digestInfo.validate(request, authorization, config)) { + principal = digestInfo.authenticate(context.getRealm()); + } + if (principal != null) { String username = parseUsername(authorization); register(request, response, principal, @@ -188,9 +281,10 @@ public boolean authenticate(Request request, // Next, generate a nOnce token (that is a token which is supposed // to be unique). - String nOnce = generateNOnce(request); + String nonce = generateNonce(request); - setAuthenticateHeader(request, response, config, nOnce); + setAuthenticateHeader(request, response, config, nonce, + digestInfo.isNonceStale()); response.sendError(HttpServletResponse.SC_UNAUTHORIZED); // hres.flushBuffer(); return (false); @@ -207,92 +301,6 @@ protected String getAuthMethod() { // ------------------------------------------------------ Protected Methods - /** - * Parse the specified authorization credentials, and return the - * associated Principal that these credentials authenticate (if any) - * from the specified Realm. If there is no such Principal, return - * null. - * - * @param request HTTP servlet request - * @param authorization Authorization credentials from this request - * @param realm Realm used to authenticate Principals - */ - protected static Principal findPrincipal(Request request, - String authorization, - Realm realm) { - - //System.out.println("Authorization token : " + authorization); - // Validate the authorization credentials format - if (authorization == null) - return (null); - if (!authorization.startsWith("Digest ")) - return (null); - authorization = authorization.substring(7).trim(); - - // Bugzilla 37132: http://issues.apache.org/bugzilla/show_bug.cgi?id=37132 - String[] tokens = authorization.split(",(?=(?:[^\"]*\"[^\"]*\")+$)"); - - String userName = null; - String realmName = null; - String nOnce = null; - String nc = null; - String cnonce = null; - String qop = null; - String uri = null; - String response = null; - String method = request.getMethod(); - - for (int i = 0; i < tokens.length; i++) { - String currentToken = tokens[i]; - if (currentToken.length() == 0) - continue; - - int equalSign = currentToken.indexOf('='); - if (equalSign < 0) - return null; - String currentTokenName = - currentToken.substring(0, equalSign).trim(); - String currentTokenValue = - currentToken.substring(equalSign + 1).trim(); - if ("username".equals(currentTokenName)) - userName = removeQuotes(currentTokenValue); - if ("realm".equals(currentTokenName)) - realmName = removeQuotes(currentTokenValue, true); - if ("nonce".equals(currentTokenName)) - nOnce = removeQuotes(currentTokenValue); - if ("nc".equals(currentTokenName)) - nc = removeQuotes(currentTokenValue); - if ("cnonce".equals(currentTokenName)) - cnonce = removeQuotes(currentTokenValue); - if ("qop".equals(currentTokenName)) - qop = removeQuotes(currentTokenValue); - if ("uri".equals(currentTokenName)) - uri = removeQuotes(currentTokenValue); - if ("response".equals(currentTokenName)) - response = removeQuotes(currentTokenValue); - } - - if ( (userName == null) || (realmName == null) || (nOnce == null) - || (uri == null) || (response == null) ) - return null; - - // Second MD5 digest used to calculate the digest : - // MD5(Method + ":" + uri) - String a2 = method + ":" + uri; - //System.out.println("A2:" + a2); - - byte[] buffer = null; - synchronized (md5Helper) { - buffer = md5Helper.digest(a2.getBytes()); - } - String md5a2 = md5Encoder.encode(buffer); - - return (realm.authenticate(userName, response, nOnce, nc, cnonce, qop, - realmName, md5a2)); - - } - - /** * Parse the username from the specified authorization string. If none * can be identified, return null @@ -301,7 +309,6 @@ protected static Principal findPrincipal(Request request, */ protected String parseUsername(String authorization) { - //System.out.println("Authorization token : " + authorization); // Validate the authorization credentials format if (authorization == null) return (null); @@ -361,20 +368,20 @@ protected static String removeQuotes(String quotedString) { * * @param request HTTP Servlet request */ - protected String generateNOnce(Request request) { + protected String generateNonce(Request request) { long currentTime = System.currentTimeMillis(); - String nOnceValue = request.getRemoteAddr() + ":" + - currentTime + ":" + key; + + String ipTimeKey = + request.getRemoteAddr() + ":" + currentTime + ":" + getKey(); - byte[] buffer = null; + byte[] buffer; synchronized (md5Helper) { - buffer = md5Helper.digest(nOnceValue.getBytes()); + buffer = md5Helper.digest(ipTimeKey.getBytes()); } - nOnceValue = md5Encoder.encode(buffer); - return nOnceValue; + return currentTime + ":" + md5Encoder.encode(buffer); } @@ -408,24 +415,298 @@ protected String generateNOnce(Request request) { protected void setAuthenticateHeader(HttpServletRequest request, HttpServletResponse response, LoginConfig config, - String nOnce) { + String nOnce, + boolean isNonceStale) { // Get the realm name String realmName = config.getRealmName(); if (realmName == null) realmName = REALM_NAME; - byte[] buffer = null; - synchronized (md5Helper) { - buffer = md5Helper.digest(nOnce.getBytes()); + String authenticateHeader; + if (isNonceStale) { + authenticateHeader = "Digest realm=\"" + realmName + "\", " + + "qop=\"" + QOP + "\", nonce=\"" + nOnce + "\", " + "opaque=\"" + + getOpaque() + "\", stale=true"; + } else { + authenticateHeader = "Digest realm=\"" + realmName + "\", " + + "qop=\"" + QOP + "\", nonce=\"" + nOnce + "\", " + "opaque=\"" + + getOpaque() + "\""; } - String authenticateHeader = "Digest realm=\"" + realmName + "\", " - + "qop=\"auth\", nonce=\"" + nOnce + "\", " + "opaque=\"" - + md5Encoder.encode(buffer) + "\""; response.setHeader(AUTH_HEADER_NAME, authenticateHeader); } + // ------------------------------------------------------- Lifecycle Methods + + @Override + protected synchronized void startInternal() throws LifecycleException { + super.startInternal(); + + // Generate a random secret key + if (getKey() == null) { + setKey(sessionIdGenerator.generateSessionId()); + } + + // Generate the opaque string the same way + if (getOpaque() == null) { + setOpaque(sessionIdGenerator.generateSessionId()); + } + + cnonces = new LinkedHashMap() { + + private static final long serialVersionUID = 1L; + private static final long LOG_SUPPRESS_TIME = 5 * 60 * 1000; + + private long lastLog = 0; + + @Override + protected boolean removeEldestEntry( + Map.Entry eldest) { + // This is called from a sync so keep it simple + long currentTime = System.currentTimeMillis(); + if (size() > getCnonceCacheSize()) { + if (lastLog < currentTime && + currentTime - eldest.getValue().getTimestamp() < + getNonceValidity()) { + // Replay attack is possible + log.warn(sm.getString( + "digestAuthenticator.cacheRemove")); + lastLog = currentTime + LOG_SUPPRESS_TIME; + } + return true; + } + return false; + } + }; + } + + private static class DigestInfo { + + private String opaque; + private long nonceValidity; + private String key; + private Map cnonces; + private boolean validateUri = true; + + private String userName = null; + private String method = null; + private String uri = null; + private String response = null; + private String nonce = null; + private String nc = null; + private String cnonce = null; + private String realmName = null; + private String qop = null; + + private boolean nonceStale = false; + + + public DigestInfo(String opaque, long nonceValidity, String key, + Map cnonces, boolean validateUri) { + this.opaque = opaque; + this.nonceValidity = nonceValidity; + this.key = key; + this.cnonces = cnonces; + this.validateUri = validateUri; + } + + public boolean validate(Request request, String authorization, + LoginConfig config) { + // Validate the authorization credentials format + if (authorization == null) { + return false; + } + if (!authorization.startsWith("Digest ")) { + return false; + } + authorization = authorization.substring(7).trim(); + + // Bugzilla 37132: http://issues.apache.org/bugzilla/show_bug.cgi?id=37132 + String[] tokens = authorization.split(",(?=(?:[^\"]*\"[^\"]*\")+$)"); + + method = request.getMethod(); + String opaque = null; + + for (int i = 0; i < tokens.length; i++) { + String currentToken = tokens[i]; + if (currentToken.length() == 0) + continue; + + int equalSign = currentToken.indexOf('='); + if (equalSign < 0) { + return false; + } + String currentTokenName = + currentToken.substring(0, equalSign).trim(); + String currentTokenValue = + currentToken.substring(equalSign + 1).trim(); + if ("username".equals(currentTokenName)) + userName = removeQuotes(currentTokenValue); + if ("realm".equals(currentTokenName)) + realmName = removeQuotes(currentTokenValue, true); + if ("nonce".equals(currentTokenName)) + nonce = removeQuotes(currentTokenValue); + if ("nc".equals(currentTokenName)) + nc = removeQuotes(currentTokenValue); + if ("cnonce".equals(currentTokenName)) + cnonce = removeQuotes(currentTokenValue); + if ("qop".equals(currentTokenName)) + qop = removeQuotes(currentTokenValue); + if ("uri".equals(currentTokenName)) + uri = removeQuotes(currentTokenValue); + if ("response".equals(currentTokenName)) + response = removeQuotes(currentTokenValue); + if ("opaque".equals(currentTokenName)) + opaque = removeQuotes(currentTokenValue); + } + + if ( (userName == null) || (realmName == null) || (nonce == null) + || (uri == null) || (response == null) ) { + return false; + } + + // Validate the URI - should match the request line sent by client + if (validateUri) { + String uriQuery; + String query = request.getQueryString(); + if (query == null) { + uriQuery = request.getRequestURI(); + } else { + uriQuery = request.getRequestURI() + "?" + query; + } + if (!uri.equals(uriQuery)) { + return false; + } + } + + // Validate the Realm name + String lcRealm = config.getRealmName(); + if (lcRealm == null) { + lcRealm = REALM_NAME; + } + if (!lcRealm.equals(realmName)) { + return false; + } + + // Validate the opaque string + if (!this.opaque.equals(opaque)) { + return false; + } + + // Validate nonce + int i = nonce.indexOf(":"); + if (i < 0 || (i + 1) == nonce.length()) { + return false; + } + long nOnceTime; + try { + nOnceTime = Long.parseLong(nonce.substring(0, i)); + } catch (NumberFormatException nfe) { + return false; + } + String md5clientIpTimeKey = nonce.substring(i + 1); + long currentTime = System.currentTimeMillis(); + if ((currentTime - nOnceTime) > nonceValidity) { + nonceStale = true; + return false; + } + String serverIpTimeKey = + request.getRemoteAddr() + ":" + nOnceTime + ":" + key; + byte[] buffer = null; + synchronized (md5Helper) { + buffer = md5Helper.digest(serverIpTimeKey.getBytes()); + } + String md5ServerIpTimeKey = md5Encoder.encode(buffer); + if (!md5ServerIpTimeKey.equals(md5clientIpTimeKey)) { + return false; + } + + // Validate qop + if (qop != null && !QOP.equals(qop)) { + return false; + } + + // Validate cnonce and nc + // Check if presence of nc and nonce is consistent with presence of qop + if (qop == null) { + if (cnonce != null || nc != null) { + return false; + } + } else { + if (cnonce == null || nc == null) { + return false; + } + if (nc.length() != 8) { + return false; + } + long count; + try { + count = Long.parseLong(nc, 16); + } catch (NumberFormatException nfe) { + return false; + } + NonceInfo info; + synchronized (cnonces) { + info = cnonces.get(cnonce); + } + if (info == null) { + info = new NonceInfo(); + } else { + if (count <= info.getCount()) { + return false; + } + } + info.setCount(count); + info.setTimestamp(currentTime); + synchronized (cnonces) { + cnonces.put(cnonce, info); + } + } + return true; + } + + public boolean isNonceStale() { + return nonceStale; + } + + public Principal authenticate(Realm realm) { + // Second MD5 digest used to calculate the digest : + // MD5(Method + ":" + uri) + String a2 = method + ":" + uri; + + byte[] buffer; + synchronized (md5Helper) { + buffer = md5Helper.digest(a2.getBytes()); + } + String md5a2 = md5Encoder.encode(buffer); + + return realm.authenticate(userName, response, nonce, nc, cnonce, + qop, realmName, md5a2); + } + + } + + private static class NonceInfo { + private volatile long count; + private volatile long timestamp; + + public void setCount(long l) { + count = l; + } + + public long getCount() { + return count; + } + + public void setTimestamp(long l) { + timestamp = l; + } + + public long getTimestamp() { + return timestamp; + } + } } diff --git a/java/org/apache/catalina/authenticator/LocalStrings.properties b/java/org/apache/catalina/authenticator/LocalStrings.properties index 3d084845bd04..cf5307ac9daf 100644 --- a/java/org/apache/catalina/authenticator/LocalStrings.properties +++ b/java/org/apache/catalina/authenticator/LocalStrings.properties @@ -28,6 +28,8 @@ authenticator.sessionExpired=The time allowed for the login process has been exc authenticator.unauthorized=Cannot authenticate with the provided credentials authenticator.userDataConstraint=This request violates a User Data constraint for this application +digestAuthenticator.cacheRemove=A valid entry has been removed from client nonce cache to make room for new entries. A replay attack is now possible. To prevent the possibility of replay attacks, reduce nonceValidity or increase cnonceCacheSize. Further warnings of this type will be suppressed for 5 minutes. + formAuthenticator.forwardErrorFail=Unexpected error forwarding to error page formAuthenticator.forwardLoginFail=Unexpected error forwarding to login page diff --git a/java/org/apache/catalina/authenticator/mbeans-descriptors.xml b/java/org/apache/catalina/authenticator/mbeans-descriptors.xml index 72910ccf1b76..5800ce0f2a81 100644 --- a/java/org/apache/catalina/authenticator/mbeans-descriptors.xml +++ b/java/org/apache/catalina/authenticator/mbeans-descriptors.xml @@ -90,10 +90,26 @@ type="java.lang.String" writeable="false"/> + + + + + + + + @@ -114,6 +130,10 @@ description="The name of the LifecycleState that this component is currently in" type="java.lang.String" writeable="false"/> + + auth = new ArrayList(); + auth.add(buildDigestResponse(user, pwd, digestUri, realm, "null", + "null", nc1, cnonce, qop)); + Map> reqHeaders = new HashMap>(); + reqHeaders.put(CLIENT_AUTH_HEADER, auth); + + Map> respHeaders = + new HashMap>(); + + // The first request will fail - but we need to extract the nonce + ByteChunk bc = new ByteChunk(); + int rc = getUrl("http://localhost:" + getPort() + uri, bc, reqHeaders, + respHeaders); + assertEquals(401, rc); + assertNull(bc.toString()); + + // Second request should succeed (if we use the server nonce) + auth.clear(); + if (useServerNonce) { + if (useServerOpaque) { + auth.add(buildDigestResponse(user, pwd, digestUri, realm, + getNonce(respHeaders), getOpaque(respHeaders), nc1, + cnonce, qop)); + } else { + auth.add(buildDigestResponse(user, pwd, digestUri, realm, + getNonce(respHeaders), "null", nc1, cnonce, qop)); + } + } else { + auth.add(buildDigestResponse(user, pwd, digestUri, realm, + "null", getOpaque(respHeaders), nc1, cnonce, QOP)); + } + rc = getUrl("http://localhost:" + getPort() + uri, bc, reqHeaders, + null); + + if (req2expect200) { + assertEquals(200, rc); + assertEquals("OK", bc.toString()); + } else { + assertEquals(401, rc); + assertNull(bc.toString()); + } + + // Third request should succeed if we increment nc + auth.clear(); + bc.recycle(); + bc.reset(); + auth.add(buildDigestResponse(user, pwd, digestUri, realm, + getNonce(respHeaders), getOpaque(respHeaders), nc2, cnonce, + qop)); + rc = getUrl("http://localhost:" + getPort() + uri, bc, reqHeaders, + null); + + if (req3expect200) { + assertEquals(200, rc); + assertEquals("OK", bc.toString()); + } else { + assertEquals(401, rc); + assertNull(bc.toString()); + } + } + + @Override + public void setUp() throws Exception { + super.setUp(); + + // Configure a context with digest auth and a single protected resource + Tomcat tomcat = getTomcatInstance(); + + // Must have a real docBase - just use temp + Context ctxt = tomcat.addContext(CONTEXT_PATH, + System.getProperty("java.io.tmpdir")); + + // Add protected servlet + Tomcat.addServlet(ctxt, "TesterServlet", new TesterServlet()); + ctxt.addServletMapping(URI, "TesterServlet"); + SecurityCollection collection = new SecurityCollection(); + collection.addPattern(URI); + SecurityConstraint sc = new SecurityConstraint(); + sc.addAuthRole(ROLE); + sc.addCollection(collection); + ctxt.addConstraint(sc); + + // Configure the Realm + MapRealm realm = new MapRealm(); + realm.addUser(USER, PWD); + realm.addUserRole(USER, ROLE); + ctxt.setRealm(realm); + + // Configure the authenticator + LoginConfig lc = new LoginConfig(); + lc.setAuthMethod("DIGEST"); + lc.setRealmName(REALM); + ctxt.setLoginConfig(lc); + ctxt.getPipeline().addValve(new DigestAuthenticator()); + } + + protected static String getNonce(Map> respHeaders) { + List authHeaders = + respHeaders.get(AuthenticatorBase.AUTH_HEADER_NAME); + // Assume there is only one + String authHeader = authHeaders.iterator().next(); + + int start = authHeader.indexOf("nonce=\"") + 7; + int end = authHeader.indexOf("\"", start); + return authHeader.substring(start, end); + } + + protected static String getOpaque(Map> respHeaders) { + List authHeaders = + respHeaders.get(AuthenticatorBase.AUTH_HEADER_NAME); + // Assume there is only one + String authHeader = authHeaders.iterator().next(); + + int start = authHeader.indexOf("opaque=\"") + 8; + int end = authHeader.indexOf("\"", start); + return authHeader.substring(start, end); + } + + /* + * Notes from RFC2617 + * H(data) = MD5(data) + * KD(secret, data) = H(concat(secret, ":", data)) + * A1 = unq(username-value) ":" unq(realm-value) ":" passwd + * A2 = Method ":" digest-uri-value + * request-digest = <"> < KD ( H(A1), unq(nonce-value) + ":" nc-value + ":" unq(cnonce-value) + ":" unq(qop-value) + ":" H(A2) + ) <"> + */ + private static String buildDigestResponse(String user, String pwd, + String uri, String realm, String nonce, String opaque, String nc, + String cnonce, String qop) throws NoSuchAlgorithmException { + + String a1 = user + ":" + realm + ":" + pwd; + String a2 = "GET:" + uri; + + String md5a1 = digest(a1); + String md5a2 = digest(a2); + + String response; + if (qop == null) { + response = md5a1 + ":" + nonce + ":" + md5a2; + } else { + response = md5a1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + + qop + ":" + md5a2; + } + + String md5response = digest(response); + + StringBuilder auth = new StringBuilder(); + auth.append("Digest username=\""); + auth.append(user); + auth.append("\", realm=\""); + auth.append(realm); + auth.append("\", nonce=\""); + auth.append(nonce); + auth.append("\", uri=\""); + auth.append(uri); + auth.append("\", opaque=\""); + auth.append(opaque); + auth.append("\", response=\""); + auth.append(md5response); + auth.append("\""); + if (qop != null) { + auth.append(", qop=\""); + auth.append(qop); + auth.append("\""); + } + if (nc != null) { + auth.append(", nc=\""); + auth.append(nc); + auth.append("\""); + } + if (cnonce != null) { + auth.append(", cnonce=\""); + auth.append(cnonce); + auth.append("\""); + } + + return auth.toString(); + } + + private static String digest(String input) throws NoSuchAlgorithmException { + // This is slow but should be OK as this is only a test + MessageDigest md5 = MessageDigest.getInstance("MD5"); + MD5Encoder encoder = new MD5Encoder(); + + md5.update(input.getBytes()); + return encoder.encode(md5.digest()); + } +} diff --git a/test/org/apache/catalina/authenticator/TesterDigestAuthenticatorPerformance.java b/test/org/apache/catalina/authenticator/TesterDigestAuthenticatorPerformance.java new file mode 100644 index 000000000000..f43e477266f3 --- /dev/null +++ b/test/org/apache/catalina/authenticator/TesterDigestAuthenticatorPerformance.java @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.authenticator; + +import java.io.IOException; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.catalina.Context; +import org.apache.catalina.deploy.LoginConfig; +import org.apache.catalina.deploy.SecurityCollection; +import org.apache.catalina.deploy.SecurityConstraint; +import org.apache.catalina.startup.TestTomcat.MapRealm; +import org.apache.catalina.startup.TesterServlet; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.catalina.util.MD5Encoder; +import org.apache.tomcat.util.buf.ByteChunk; + +public class TesterDigestAuthenticatorPerformance extends TomcatBaseTest { + + private static String USER = "user"; + private static String PWD = "pwd"; + private static String ROLE = "role"; + private static String URI = "/protected"; + private static String CONTEXT_PATH = "/foo"; + private static String CLIENT_AUTH_HEADER = "authorization"; + private static String REALM = "TestRealm"; + private static String QOP = "auth"; + + + public void testSimple() throws Exception { + doTest(100, 1000); + } + + public void doTest(int threadCount, int requestCount) throws Exception { + + getTomcatInstance().start(); + + TesterRunnable runnables[] = new TesterRunnable[threadCount]; + Thread threads[] = new Thread[threadCount]; + + // Create the runnables & threads + for (int i = 0; i < threadCount; i++) { + runnables[i] = new TesterRunnable(i, requestCount); + threads[i] = new Thread(runnables[i]); + } + + long start = System.currentTimeMillis(); + + // Start the threads + for (int i = 0; i < threadCount; i++) { + threads[i].start(); + } + + // Wait for the threads to finish + for (int i = 0; i < threadCount; i++) { + threads[i].join(); + } + double wallTime = System.currentTimeMillis() - start; + + // Gather the results... + double totalTime = 0; + int totalSuccess = 0; + for (int i = 0; i < threadCount; i++) { + System.out.println("Thread: " + i + " Success: " + + runnables[i].getSuccess()); + totalSuccess = totalSuccess + runnables[i].getSuccess(); + totalTime = totalTime + runnables[i].getTime(); + } + + System.out.println("Average time per request (user): " + + totalTime/(threadCount * requestCount)); + System.out.println("Average time per request (wall): " + + wallTime/(threadCount * requestCount)); + + assertEquals(requestCount * threadCount, totalSuccess); + } + + private class TesterRunnable implements Runnable { + + // Number of valid requests required + private int requestCount; + + private String nonce; + private String opaque; + + private String cnonce; + + private Map> reqHeaders; + private List authHeader; + + private MessageDigest digester; + private MD5Encoder encoder; + + private String md5a1; + private String md5a2; + + private String path; + + private int success = 0; + private long time = 0; + + // All init code should be in here. run() needs to be quick + public TesterRunnable(int id, int requestCount) throws Exception { + this.requestCount = requestCount; + + path = "http://localhost:" + getPort() + CONTEXT_PATH + URI; + + // Make the first request as we need the Digest challenge to obtain + // the server nonce + Map> respHeaders = + new HashMap>(); + getUrl(path, new ByteChunk(), respHeaders); + + nonce = TestDigestAuthenticator.getNonce(respHeaders); + opaque = TestDigestAuthenticator.getOpaque(respHeaders); + + cnonce = "cnonce" + id; + + reqHeaders = new HashMap>(); + authHeader = new ArrayList(); + reqHeaders.put(CLIENT_AUTH_HEADER, authHeader); + + digester = MessageDigest.getInstance("MD5"); + encoder = new MD5Encoder(); + + String a1 = USER + ":" + REALM + ":" + PWD; + String a2 = "GET:" + CONTEXT_PATH + URI; + + md5a1 = encoder.encode(digester.digest(a1.getBytes())); + md5a2 = encoder.encode(digester.digest(a2.getBytes())); + } + + @Override + public void run() { + int rc; + int nc = 0; + ByteChunk bc = new ByteChunk(); + long start = System.currentTimeMillis(); + for (int i = 0; i < requestCount; i++) { + nc++; + authHeader.clear(); + authHeader.add(buildDigestResponse(nc)); + + rc = -1; + bc.recycle(); + bc.reset(); + + try { + rc = getUrl(path, bc, reqHeaders, null); + } catch (IOException ioe) { + // Ignore + } + + if (rc == 200 && "OK".equals(bc.toString())) { + success++; + } + } + time = System.currentTimeMillis() - start; + } + + public int getSuccess() { + return success; + } + + public long getTime() { + return time; + } + + private String buildDigestResponse(int nc) { + + String ncString = String.format("%1$08x", Integer.valueOf(nc)); + + String response = md5a1 + ":" + nonce + ":" + ncString + ":" + + cnonce + ":" + QOP + ":" + md5a2; + + String md5response = + encoder.encode(digester.digest(response.getBytes())); + + StringBuilder auth = new StringBuilder(); + auth.append("Digest username=\""); + auth.append(USER); + auth.append("\", realm=\""); + auth.append(REALM); + auth.append("\", nonce=\""); + auth.append(nonce); + auth.append("\", uri=\""); + auth.append(CONTEXT_PATH + URI); + auth.append("\", opaque=\""); + auth.append(opaque); + auth.append("\", response=\""); + auth.append(md5response); + auth.append("\""); + auth.append(", qop=\""); + auth.append(QOP); + auth.append("\""); + auth.append(", nc=\""); + auth.append(ncString); + auth.append("\""); + auth.append(", cnonce=\""); + auth.append(cnonce); + auth.append("\""); + + return auth.toString(); + } + } + + @Override + public void setUp() throws Exception { + super.setUp(); + + // Configure a context with digest auth and a single protected resource + Tomcat tomcat = getTomcatInstance(); + + // Must have a real docBase - just use temp + Context ctxt = tomcat.addContext(CONTEXT_PATH, + System.getProperty("java.io.tmpdir")); + + // Add protected servlet + Tomcat.addServlet(ctxt, "TesterServlet", new TesterServlet()); + ctxt.addServletMapping(URI, "TesterServlet"); + SecurityCollection collection = new SecurityCollection(); + collection.addPattern(URI); + SecurityConstraint sc = new SecurityConstraint(); + sc.addAuthRole(ROLE); + sc.addCollection(collection); + ctxt.addConstraint(sc); + + // Configure the Realm + MapRealm realm = new MapRealm(); + realm.addUser(USER, PWD); + realm.addUserRole(USER, ROLE); + ctxt.setRealm(realm); + + // Configure the authenticator + LoginConfig lc = new LoginConfig(); + lc.setAuthMethod("DIGEST"); + lc.setRealmName(REALM); + ctxt.setLoginConfig(lc); + DigestAuthenticator authenticator = new DigestAuthenticator(); + authenticator.setCnonceCacheSize(100); + ctxt.getPipeline().addValve(authenticator); + } +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 52d61a0b92c4..00d62a891d93 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -147,6 +147,10 @@ Don't register non-singelton DataSource resources with JMX. (markt) + + Provide additional configuration options for the DIGEST authenticator. + (markt) + diff --git a/webapps/docs/config/valve.xml b/webapps/docs/config/valve.xml index bc8add1e4f2e..23b5bff8ed7d 100644 --- a/webapps/docs/config/valve.xml +++ b/webapps/docs/config/valve.xml @@ -549,6 +549,12 @@ org.apache.catalina.authenticator.DigestAuthenticator.

+ +

To protect against replay attacks, the DIGEST authenticator tracks + client nonce and nonce count values. This attribute controls the size + of that cache. If not specified, the default value of 1000 is used.

+
+

Controls the caching of pages that are protected by security constraints. Setting this to false may help work around @@ -559,6 +565,26 @@ true will be used.

+ +

The secret key used by digest authentication. If not set, a secure + random value is generated. This should normally only be set when it is + necessary to keep key values constant either across server restarts + and/or across a cluster.

+
+ + +

The time, in milliseconds, that a server generated nonce will be + considered valid for use in authentication. If not specified, the + default value of 300000 (5 minutes) will be used.

+
+ + +

The opaque server string used by digest authentication. If not set, a + random value is generated. This should normally only be set when it is + necessary to keep opaque values constant either across server restarts + and/or across a cluster.

+
+

Controls the caching of pages that are protected by security constraints. Setting this to false may help work around @@ -595,6 +621,14 @@ specified, the platform default provider will be used.

+ +

Should the URI be validated as required by RFC2617? If not specified, + the default value of true will be used. This should + normally only be set when Tomcat is located behind a reverse proxy and + the proxy is modifying the URI passed to Tomcat such that DIGEST + authentication always fails.

+
+