| /* |
| * 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.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; |
| import org.apache.catalina.util.MD5Encoder; |
| import org.apache.juli.logging.Log; |
| import org.apache.juli.logging.LogFactory; |
| |
| |
| |
| /** |
| * An <b>Authenticator</b> and <b>Valve</b> implementation of HTTP DIGEST |
| * Authentication (see RFC 2069). |
| * |
| * @author Craig R. McClanahan |
| * @author Remy Maucherat |
| * @version $Id: DigestAuthenticator.java,v 1.1 2011/06/28 21:08:24 rherrmann Exp $ |
| */ |
| |
| public class DigestAuthenticator extends AuthenticatorBase { |
| |
| private static final Log log = LogFactory.getLog(DigestAuthenticator.class); |
| |
| |
| // -------------------------------------------------------------- Constants |
| |
| /** |
| * The MD5 helper object for this class. |
| */ |
| protected static final MD5Encoder md5Encoder = new MD5Encoder(); |
| |
| |
| /** |
| * Descriptive information about this implementation. |
| */ |
| protected static final String info = |
| "org.apache.catalina.authenticator.DigestAuthenticator/1.0"; |
| |
| |
| /** |
| * Tomcat's DIGEST implementation only supports auth quality of protection. |
| */ |
| protected static final String QOP = "auth"; |
| |
| // ----------------------------------------------------------- Constructors |
| |
| |
| public DigestAuthenticator() { |
| super(); |
| try { |
| if (md5Helper == null) |
| md5Helper = MessageDigest.getInstance("MD5"); |
| } catch (NoSuchAlgorithmException e) { |
| e.printStackTrace(); |
| throw new IllegalStateException(); |
| } |
| } |
| |
| |
| // ----------------------------------------------------- Instance Variables |
| |
| |
| /** |
| * MD5 message digest provider. |
| */ |
| protected static volatile MessageDigest md5Helper; |
| |
| |
| /** |
| * List of client nonce values currently being tracked |
| */ |
| protected Map<String,NonceInfo> 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 = null; |
| |
| |
| /** |
| * 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. |
| */ |
| @Override |
| public String getInfo() { |
| |
| return (info); |
| |
| } |
| |
| |
| 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 <code>true</code> if any specified |
| * constraint has been satisfied, or <code>false</code> if we have |
| * created a response challenge already. |
| * |
| * @param request Request we are processing |
| * @param response Response we are creating |
| * @param config Login configuration describing how authentication |
| * should be performed |
| * |
| * @exception IOException if an input/output error occurs |
| */ |
| @Override |
| public boolean authenticate(Request request, |
| HttpServletResponse response, |
| LoginConfig config) |
| throws IOException { |
| |
| // Have we already authenticated someone? |
| Principal principal = request.getUserPrincipal(); |
| //String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE); |
| if (principal != null) { |
| if (log.isDebugEnabled()) |
| log.debug("Already authenticated '" + principal.getName() + "'"); |
| // Associate the session with any existing SSO session in order |
| // to get coordinated session invalidation at logout |
| String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE); |
| if (ssoId != null) |
| associate(ssoId, request.getSessionInternal(true)); |
| return (true); |
| } |
| |
| // NOTE: We don't try to reauthenticate using any existing SSO session, |
| // because that will only work if the original authentication was |
| // BASIC or FORM, which are less secure than the DIGEST auth-type |
| // specified for this webapp |
| // |
| // Uncomment below to allow previous FORM or BASIC authentications |
| // to authenticate users for this webapp |
| // TODO make this a configurable attribute (in SingleSignOn??) |
| /* |
| // Is there an SSO session against which we can try to reauthenticate? |
| if (ssoId != null) { |
| if (log.isDebugEnabled()) |
| log.debug("SSO Id " + ssoId + " set; attempting " + |
| "reauthentication"); |
| // Try to reauthenticate using data cached by SSO. If this fails, |
| // either the original SSO logon was of DIGEST or SSL (which |
| // we can't reauthenticate ourselves because there is no |
| // cached username and password), or the realm denied |
| // the user's reauthentication for some reason. |
| // In either case we have to prompt the user for a logon |
| if (reauthenticateFromSSO(ssoId, request)) |
| return true; |
| } |
| */ |
| |
| // 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) { |
| if (digestInfo.validate(request, authorization, config)) { |
| principal = digestInfo.authenticate(context.getRealm()); |
| } |
| |
| if (principal != null) { |
| String username = parseUsername(authorization); |
| register(request, response, principal, |
| Constants.DIGEST_METHOD, |
| username, null); |
| return (true); |
| } |
| } |
| |
| // Send an "unauthorized" response and an appropriate challenge |
| |
| // Next, generate a nOnce token (that is a token which is supposed |
| // to be unique). |
| String nonce = generateNonce(request); |
| |
| setAuthenticateHeader(request, response, config, nonce, |
| digestInfo.isNonceStale()); |
| response.sendError(HttpServletResponse.SC_UNAUTHORIZED); |
| // hres.flushBuffer(); |
| return (false); |
| |
| } |
| |
| |
| @Override |
| protected String getAuthMethod() { |
| return Constants.DIGEST_METHOD; |
| } |
| |
| |
| // ------------------------------------------------------ Protected Methods |
| |
| |
| /** |
| * Parse the username from the specified authorization string. If none |
| * can be identified, return <code>null</code> |
| * |
| * @param authorization Authorization string to be parsed |
| */ |
| protected String parseUsername(String authorization) { |
| |
| // Validate the authorization credentials format |
| if (authorization == null) |
| return (null); |
| if (!authorization.startsWith("Digest ")) |
| return (null); |
| authorization = authorization.substring(7).trim(); |
| |
| StringTokenizer commaTokenizer = |
| new StringTokenizer(authorization, ","); |
| |
| while (commaTokenizer.hasMoreTokens()) { |
| String currentToken = commaTokenizer.nextToken(); |
| 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)) |
| return (removeQuotes(currentTokenValue)); |
| } |
| |
| return (null); |
| |
| } |
| |
| |
| /** |
| * Removes the quotes on a string. RFC2617 states quotes are optional for |
| * all parameters except realm. |
| */ |
| protected static String removeQuotes(String quotedString, |
| boolean quotesRequired) { |
| //support both quoted and non-quoted |
| if (quotedString.length() > 0 && quotedString.charAt(0) != '"' && |
| !quotesRequired) { |
| return quotedString; |
| } else if (quotedString.length() > 2) { |
| return quotedString.substring(1, quotedString.length() - 1); |
| } else { |
| return ""; |
| } |
| } |
| |
| /** |
| * Removes the quotes on a string. |
| */ |
| protected static String removeQuotes(String quotedString) { |
| return removeQuotes(quotedString, false); |
| } |
| |
| /** |
| * Generate a unique token. The token is generated according to the |
| * following pattern. NOnceToken = Base64 ( MD5 ( client-IP ":" |
| * time-stamp ":" private-key ) ). |
| * |
| * @param request HTTP Servlet request |
| */ |
| protected String generateNonce(Request request) { |
| |
| long currentTime = System.currentTimeMillis(); |
| |
| |
| String ipTimeKey = |
| request.getRemoteAddr() + ":" + currentTime + ":" + getKey(); |
| |
| byte[] buffer; |
| synchronized (md5Helper) { |
| buffer = md5Helper.digest(ipTimeKey.getBytes()); |
| } |
| |
| return currentTime + ":" + md5Encoder.encode(buffer); |
| } |
| |
| |
| /** |
| * Generates the WWW-Authenticate header. |
| * <p> |
| * The header MUST follow this template : |
| * <pre> |
| * WWW-Authenticate = "WWW-Authenticate" ":" "Digest" |
| * digest-challenge |
| * |
| * digest-challenge = 1#( realm | [ domain ] | nOnce | |
| * [ digest-opaque ] |[ stale ] | [ algorithm ] ) |
| * |
| * realm = "realm" "=" realm-value |
| * realm-value = quoted-string |
| * domain = "domain" "=" <"> 1#URI <"> |
| * nonce = "nonce" "=" nonce-value |
| * nonce-value = quoted-string |
| * opaque = "opaque" "=" quoted-string |
| * stale = "stale" "=" ( "true" | "false" ) |
| * algorithm = "algorithm" "=" ( "MD5" | token ) |
| * </pre> |
| * |
| * @param request HTTP Servlet request |
| * @param response HTTP Servlet response |
| * @param config Login configuration describing how authentication |
| * should be performed |
| * @param nOnce nonce token |
| */ |
| protected void setAuthenticateHeader(HttpServletRequest request, |
| HttpServletResponse response, |
| LoginConfig config, |
| String nOnce, |
| boolean isNonceStale) { |
| |
| // Get the realm name |
| String realmName = config.getRealmName(); |
| if (realmName == null) |
| realmName = REALM_NAME; |
| |
| 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() + "\""; |
| } |
| |
| 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<String, DigestAuthenticator.NonceInfo>() { |
| |
| 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<String,NonceInfo> 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<String,NonceInfo> 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<String,NonceInfo> 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; |
| } |
| } |
| } |