package org.cdlib.xtf.dynaXML; /** * Copyright (c) 2004, Regents of the University of California * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * - Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * - Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * - Neither the name of the University of California nor the names of its * contributors may be used to endorse or promote products derived from this * software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ import java.net.URLEncoder; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Enumeration; import java.util.Hashtable; import java.util.Vector; import javax.naming.directory.Attribute; import javax.naming.directory.DirContext; import javax.naming.directory.InitialDirContext; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.cdlib.xtf.cache.*; import org.cdlib.xtf.util.*; /** * Performs all authentication tasks for the servlet, including IP-based, * LDAP, and external authentication. */ class Authenticator { /** Used for generating random nonce values */ private SecureRandom secureRandom = new SecureRandom(); /** Caches IP maps */ private IpListCache ipListCache; /** Caches authorized session IDs */ private StringCache authCache; /** Caches nonce values for external log-ins */ private StringCache loginCache; /** Servlet to get dependencies from */ private DynaXML servlet; /** Configuration info */ private DynaXMLConfig config; /** * Construct an authorizer, initializing all the caches. * * @param servlet Servlet whose cache we will access */ public Authenticator(DynaXML servlet) { this.servlet = servlet; this.config = (DynaXMLConfig)servlet.getConfig(); authCache = new StringCache("AuthCache", config.authCacheSize, config.authCacheExpire); loginCache = new StringCache("LoginCache", config.loginCacheSize, config.loginCacheExpire); ipListCache = new IpListCache(config.ipListCacheSize, config.ipListCacheExpire, config.dependencyCheckingEnabled); } // constructor /** Utility method to check if a string is null or "" */ private boolean isEmpty(String s) { return (s == null || s.equals("")); } // isEmpty() /** * Creates an AuthSpec from an 'auth' element produced by the docReqParser * stylesheet. Parses the various parameters depending on the type. */ public AuthSpec processAuthTag(EasyNode el) throws DynaXMLException { AuthSpec spec = null; // Output extended debugging info if requested. if (Trace.getOutputLevel() >= Trace.debug) { StringBuffer buf = new StringBuffer(); for (int i = 0; i < el.nAttrs(); i++) { String name = el.attrName(i); buf.append(name + "=" + el.attrValue(i) + " "); } Trace.debug("Processing auth spec: " + buf.toString()); } // Make sure a type is specified if (!el.hasAttr("type")) throw new DynaXMLException("Auth type not specified by docReqParserSheet"); // Look for different parameters, depending on the type. String type = el.attrValue("type"); if (type.equals("all")) { spec = new AllAuthSpec(); spec.type = AuthSpec.TYPE_ALL; } else if (type.equals("IP")) { spec = new IPAuthSpec(); spec.type = AuthSpec.TYPE_IP; if (!el.hasAttr("list")) throw new DynaXMLException( "Auth IP 'list' not specified by docReqParserSheet"); ((IPAuthSpec)spec).ipList = servlet.getRealPath(el.attrValue("list")); } else if (type.equals("LDAP")) { LdapAuthSpec lspec = new LdapAuthSpec(); spec = lspec; spec.type = AuthSpec.TYPE_LDAP; lspec.realm = el.attrValue("realm"); lspec.server = el.attrValue("server"); lspec.bindName = el.attrValue("bindName"); lspec.bindPassword = el.attrValue("bindPassword"); lspec.queryName = el.attrValue("queryName"); lspec.matchField = el.attrValue("matchField"); lspec.matchValue = el.attrValue("matchValue"); if (isEmpty(lspec.server)) throw new DynaXMLException( "LDAP server not specified by docReqParserSheet"); if (isEmpty(lspec.queryName)) throw new DynaXMLException( "LDAP queryName not specified by docReqParserSheet"); if ((isEmpty(lspec.matchField) && !isEmpty(lspec.matchValue)) || (!isEmpty(lspec.matchField) && isEmpty(lspec.matchValue))) { throw new DynaXMLException( "LDAP matchField and matchValue must be either " + "both present or both absent in docReqParserSheet."); } if (isEmpty(lspec.bindName) && isEmpty(lspec.matchField)) { throw new DynaXMLException( "Either LDAP bindName or matchField must be " + "specified by docReqParserSheet."); } } else if (type.equals("external")) { ExternalAuthSpec espec = new ExternalAuthSpec(); spec = espec; spec.type = AuthSpec.TYPE_EXTERNAL; espec.url = el.attrValue("url"); espec.secretKey = el.attrValue("key"); if (isEmpty(espec.url)) throw new DynaXMLException( "External authorization page url not specified " + "by docReqParserSheet"); if (isEmpty(espec.secretKey)) throw new DynaXMLException( "External authorization key (secret) not specified " + "by docReqParserSheet"); } else throw new DynaXMLException( "Invalid auth type '" + type + "' specified by docReqParserSheet"); // Make sure an access mode (allow or deny) has been specified. if (!el.hasAttr("access")) throw new DynaXMLException( "Auth access (allow or deny) " + "must be specified by docReqParserSheet"); // Record it. String access = el.attrValue("access"); if (access.equals("allow")) spec.access = AuthSpec.ACCESS_ALLOW; else if (access.equals("deny")) spec.access = AuthSpec.ACCESS_DENY; else throw new DynaXMLException( "Invalid access '" + access + "' specified by docReqParser"); // And we're done. return spec; } // processAuthTag() /** Clears all the caches used by the authenticator. */ public void clearCaches() { ipListCache.clear(); authCache.clear(); loginCache.clear(); } // clearCaches() /** * Uses an LDAP server to authorize user access with a username and * password. Name and password are gathered using the HTTP 'basic' * authentication mechanism. * * @param spec The authorization spec containing details (server to * connect to, what to look up, etc.) * @param req The HTTP request (contains username and password) * @param res The HTTP response (only used to re-request user auth) * * @throws NoPermissionException * If permission isn't granted, or the browser must re-validate * the password. * @throws Exception * Communication or other miscellaneous problems. */ private void authLdap(LdapAuthSpec spec, HttpServletRequest req, HttpServletResponse res) throws Exception { // If we've already authorized this session, no need to do more. HttpSession session = req.getSession(); String sessionID = req.getSession().getId(); String authCacheKey = sessionID + ":LDAP" + ":" + spec.server + ":" + spec.bindName + ":" + spec.queryName + ":" + spec.matchField + ":" + spec.matchValue; if (authCache.has(authCacheKey)) return; // Figure out the "realm" string. This displays in the dialog box // the user's browser presents when asking for username/password. // String realm = isEmpty(spec.realm) ? "dynaXML" : spec.realm; // The first time we see a new session, force the browser to re-request // the password from the user. // if (session.getAttribute("LDAP_attempted") == null) { session.setAttribute("LDAP_attempted", new Boolean(true)); Trace.debug( "New session (" + session.getId() + ")... " + "forcing re-authentication"); res.addHeader("WWW-Authenticate", "Basic realm=\"" + realm + "\""); res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); throw new NoPermissionException(); } else session.removeAttribute("LDAP_attempted"); // If the HTTP header has a user name and password in it (via the // "Authorization" header), then pick them out. // String userName = ""; String password = ""; String auth = req.getHeader("Authorization"); if (auth != null) { int spacePos = auth.indexOf(' '); if (spacePos >= 0) { String method = auth.substring(0, spacePos); String stuff = auth.substring(spacePos + 1); if (method.equals("Basic")) { String decoded = Base64.decodeString(stuff); int colonPos = decoded.indexOf(':'); if (colonPos >= 0) { userName = decoded.substring(0, colonPos); password = decoded.substring(colonPos + 1); } } } } // Make a temporary spec that's a copy with the substituted values. LdapAuthSpec oldSpec = spec; spec = new LdapAuthSpec(); spec.realm = oldSpec.realm; spec.server = oldSpec.server; spec.bindName = oldSpec.bindName.replaceAll("\\%", userName); spec.bindPassword = oldSpec.bindPassword.replaceAll("\\%", password); spec.queryName = oldSpec.queryName.replaceAll("\\%", userName); spec.matchField = oldSpec.matchField; spec.matchValue = oldSpec.matchValue.replaceAll("\\%", password); // Set up the environment variables for connecting to the LDAP server. Hashtable env = new Hashtable(); env.put(javax.naming.Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); env.put(javax.naming.Context.PROVIDER_URL, spec.server); // Fill in the security parameters for the LDAP bind. env.put(javax.naming.Context.SECURITY_AUTHENTICATION, "simple"); if (!isEmpty(spec.bindName)) env.put(javax.naming.Context.SECURITY_PRINCIPAL, spec.bindName); if (!isEmpty(spec.bindPassword)) env.put(javax.naming.Context.SECURITY_CREDENTIALS, spec.bindPassword); try { // Now try to connect to the LDAP server and look up the entry. // If these fail an exception will be thrown (caught below). // DirContext ctx = new InitialDirContext(env); javax.naming.directory.Attributes attribs; attribs = ctx.getAttributes(spec.queryName); // If we got no attributes, access denied. if (attribs.size() == 0) throw new NoPermissionException(); // If a 'matchField' was specified, look for it. if (!isEmpty(spec.matchField)) { Attribute attrib = attribs.get(spec.matchField); if (attrib == null) { Trace.warning( "[sensitive] LDAP: Cannot find field '" + spec.matchField + "'"); throw new NoPermissionException(); } // Check that the value matches. if (!attrib.contains(spec.matchValue)) { Trace.warning( "[sensitive] " + "LDAP: Cannot match value '" + spec.matchValue + "' for field '" + spec.matchField + "'"); throw new NoPermissionException(); } // if } // if } // try catch (Exception e) { // Output lots of log info to help find deployment problems. Trace.warning( "[sensitive] LDAP authentication failure: " + e.getClass().getName() + " - " + e.getMessage() + ". " + "server='" + spec.server + "', " + "bindName='" + spec.bindName + "', " + "bindPassword='" + spec.bindPassword + "', " + "queryName='" + spec.queryName + "', " + "matchField='" + spec.matchField + "', " + "matchValue='" + spec.matchValue + "'"); // Notify the user's browser to ask for a username and password. res.addHeader("WWW-Authenticate", "Basic realm=\"" + realm + "\""); res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // Don't force re-re-authentication. session.setAttribute("LDAP_attempted", new Boolean(true)); throw new NoPermissionException(e); } // Record that this session has been authorized. authCache.set(authCacheKey, "LDAP"); } // authLdap() /** * Uses an external login web page to authorize user access. Redirects * to an external login page and sends a 'nonce' value along with the * redirect. Eventually the login gets back to our page with an encrypted * version of the nonce so we can prevent spurious returns. * * @param spec The authorization spec containing URL to contact. * @param req The HTTP request (contains nonce when we get the return * from the authorization page). * @param res The HTTP response * * @return true if granted, false if redirected * * @throws NoPermissionException * If permission isn't granted. * @throws Exception * For miscellaneous problems. */ private boolean authExternal(ExternalAuthSpec spec, HttpServletRequest req, HttpServletResponse res) throws Exception { // If we've already authorized this session, no need to do more. HttpSession session = req.getSession(); String sessionID = req.getSession().getId(); String authCacheKey = sessionID + ":ext:" + spec.url; if (authCache.has(authCacheKey)) return true; // If this is the return response from the authorization page, // validate it. // if (req.getParameter("hash") != null && req.getParameter("nonce") != null) { String nonce = req.getParameter("nonce"); String hash = req.getParameter("hash"); // Ensure that the nonce is still valid. if (sessionID.equals(loginCache.get(nonce))) { // Invalidate this nonce so it cannot be used in a replay // attack. // loginCache.remove(nonce); // Compute our own hash of the nonce and the key. String strToHash = nonce + ":" + spec.secretKey; MessageDigest digest = MessageDigest.getInstance("MD5"); for (int i = 0; i < strToHash.length(); i++) digest.update((byte)strToHash.charAt(i)); String compHash = bytesToHex(digest.digest()); // If it's the same, the authentication is successful. if (compHash.equals(hash)) { // Record that this session has been authorized. authCache.set(authCacheKey, "ext"); // Now redirect to the original URL, minus all the // authorization stuff. // res.addHeader("Cache-Control", "no-cache"); res.sendRedirect((String)session.getAttribute("Ext_orig_url")); res.addHeader("Cache-Control", "no-cache"); return false; } // Otherwise, we better try again. Trace.warning("Hash " + hash + " doesn't match calc'd " + compHash); } // if else if (loginCache.has(nonce)) { Trace.warning( "Bad external session: " + loginCache.get(nonce) + " turned into " + sessionID); loginCache.remove(nonce); } } // if // Okay, the user needs to log in using the external authentication // web page. First, construct the URL that will return to this page. // It should be the root URL, plus all parameters except a leftover // 'nonce' or 'hash' from a failed attempt. // StringBuffer returnUrl = req.getRequestURL(); Enumeration e = req.getParameterNames(); boolean first = true; while (e.hasMoreElements()) { String name = (String)e.nextElement(); if (name.equals("nonce") || name.equals("hash")) continue; returnUrl.append(first ? "?" : "&"); returnUrl.append(name + "=" + req.getParameter(name)); first = false; } // Store this (without all the session, nonce, etc. stuff) as the // URL to go to when authorization is all finished. // session.setAttribute("Ext_orig_url", returnUrl.toString()); // Create a random nonce that the validator will need to encode. byte[] bytes = new byte[16]; secureRandom.nextBytes(bytes); String nonce = bytesToHex(bytes); // Put the nonce in a cache that will time it out eventually. loginCache.set(nonce, sessionID); // Now construct the final redirect URL. StringBuffer finalUrl = new StringBuffer(spec.url); finalUrl.append( "?returnto=" + URLEncoder.encode(returnUrl.toString(), "UTF-8")); finalUrl.append("&nonce=" + nonce); // Redirect the client to that location. res.addHeader("Cache-Control", "no-cache"); res.sendRedirect(finalUrl.toString()); res.addHeader("Cache-Control", "no-cache"); return false; } // authExternal() /** * Based on a list of authentication specifications, checks if the * current session is allowed to access this document. Handles IP-based, * LDAP, and external authentication methods. * * @param ipAddr Real IP address of the requestor * @param authSpecs List of authentication specifications (allow/deny), * processed in order. * @param req The HTTP request that was made * @param res The HTTP response being generated * * @return true if ok, false to redirect. * * @throws NoPermissionException * Authentication failed * @throws Exception * Miscellaneous problems */ public boolean checkAuth(String ipAddr, Vector authSpecs, HttpServletRequest req, HttpServletResponse res) throws Exception { // See if the requestor has permission to access this document. boolean allow = false; for (int i = 0; !allow && i < authSpecs.size(); i++) { AuthSpec spec = (AuthSpec)authSpecs.get(i); switch (spec.type) { case AuthSpec.TYPE_ALL: if (spec.access == AuthSpec.ACCESS_ALLOW) { Trace.debug("Auth allow all"); allow = true; } else { Trace.debug("Auth deny all"); throw new NoPermissionException(ipAddr); } break; case AuthSpec.TYPE_IP: IpList ipList = ipListCache.find(((IPAuthSpec)spec).ipList); boolean onList = ipList.isApproved(ipAddr); if (spec.access == AuthSpec.ACCESS_ALLOW) { Trace.debug( "Auth allow IP " + ipAddr + ": on-list=" + (onList ? "yes" : "no")); if (onList) allow = true; } else { Trace.debug( "Auth deny IP " + ipAddr + ": on-list=" + (onList ? "yes" : "no")); if (onList) throw new NoPermissionException(ipAddr); } break; case AuthSpec.TYPE_LDAP: if (spec.access == AuthSpec.ACCESS_DENY) throw new NoPermissionException(ipAddr); authLdap((LdapAuthSpec)spec, req, res); Trace.debug("Auth LDAP: ok"); allow = true; break; case AuthSpec.TYPE_EXTERNAL: if (spec.access == AuthSpec.ACCESS_DENY) throw new NoPermissionException(ipAddr); if (authExternal((ExternalAuthSpec)spec, req, res)) { Trace.debug("Auth external: yes"); allow = true; } else { Trace.debug("Auth external: no"); return false; } break; default: throw new DynaXMLException("Internal error"); } } if (!allow) throw new NoPermissionException(ipAddr); return true; } // checkAuth() /** * Converts an array of bytes to the hex representation of them, two * digits per byte and no spaces. * * @param bytes An array of bytes to convert * @return A long string representing those bytes in hex form */ @SuppressWarnings("cast") private static String bytesToHex(byte[] bytes) { StringBuffer buf = new StringBuffer(); for (int i = 0; i < bytes.length; i++) { int n = (int)(bytes[i]) & 0xff; if (n < 16) buf.append("0"); buf.append(Integer.toHexString(n)); } return buf.toString(); } // bytesToHex() /** Holds information on a particular authorization specification */ private class AuthSpec { public static final int ACCESS_DENY = 0; public static final int ACCESS_ALLOW = 1; public static final int TYPE_ALL = 0; public static final int TYPE_IP = 1; public static final int TYPE_LDAP = 2; public static final int TYPE_EXTERNAL = 3; int access; int type; } /** Allow or deny all access */ private class AllAuthSpec extends AuthSpec { } /** * Allow or deny based on whether requestor's IP address is in the * specified list. */ private class IPAuthSpec extends AuthSpec { String ipList; } /** Allow or deny based on looking up an entry in an LDAP database. */ private class LdapAuthSpec extends AuthSpec { String realm; String server; String bindName; String bindPassword; String queryName; String matchField; String matchValue; } /** Allow or deny based on an external login page */ private class ExternalAuthSpec extends AuthSpec { String url; String secretKey; } /** * This class is used to cache IP maps so we don't have to load the * same ones over and over. */ private class IpListCache extends GeneratingCache { private boolean dependencyChecking; /** Constructor - initializes the cache */ public IpListCache(int maxEntries, int maxTime, boolean dependencyChecking) { super(maxEntries, maxTime); this.dependencyChecking = dependencyChecking; } /** * Locate the IP list for the given path. * * @param path The full filesystem path of the IP list to * load. * @throws Exception If not found or invalid format */ public IpList find(String path) throws Exception { return (IpList)super.find(path); } /** * Load an IP list from the filesystem. * @param key Full path of the file to load * @throws Exception If not found or bad format. */ protected Object generate(Object key) throws Exception { String path = (String)key; if (dependencyChecking) addDependency(new FileDependency(path)); // Try to load it. On failure, it will throw an exception (which // will be handled by doGet(). // return new IpList(path); } // generate() /** Prints out useful debugging info */ protected void logAction(String action, Object key, Object value) { Trace.warning("IpListCache: " + action + ". Path=" + (String)key); } // logAction() } // class IpListCache } // class Authenticator