/* $Id: LDAPAuthority.java 1865954 2019-08-26 20:27:25Z schuch $ */
/**
 * 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.manifoldcf.authorities.authorities.ldap;

import java.io.*;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.naming.*;
import javax.naming.directory.*;
import javax.naming.ldap.*;
import org.apache.manifoldcf.authorities.interfaces.*;
import org.apache.manifoldcf.authorities.system.ManifoldCF;
import org.apache.manifoldcf.authorities.system.Logging;
import org.apache.manifoldcf.core.interfaces.*;
import org.apache.manifoldcf.connectorcommon.interfaces.*;
import org.apache.manifoldcf.ui.util.Encoder;
import org.apache.manifoldcf.core.common.LDAPSSLSocketFactory;

/**
 * This is the Active Directory implementation of the IAuthorityConnector
 * interface. Access tokens for this connector are simple SIDs, except for the
 * "global deny" token, which is designed to allow the authority to shut off
 * access to all authorized documents when the user is unrecognized or the
 * domain controller does not respond.
 */
public class LDAPAuthority extends org.apache.manifoldcf.authorities.authorities.BaseAuthorityConnector {

  public static final String _rcsid = "@(#)$Id: LDAPAuthority.java 1865954 2019-08-26 20:27:25Z schuch $";

  /**
   * Session information for all DC's we talk with.
   */
  private LdapContext session = null;
  private StartTlsResponse tls = null;
  
  private long sessionExpirationTime = -1L;

  //private ConfigParams parameters;

  private String bindUser;
  private String bindPass;
  private String serverProtocol;
  private String serverName;
  private String serverPort;
  private String serverBase;
  private String userBase;
  private String userSearch;
  private String groupBase;
  private String groupSearch;
  private String groupNameAttr;
  private boolean groupMemberDN;
  private boolean addUserRecord;
  private List<String> forcedTokens;
  private String userNameAttr;
  private String sslKeystoreData;
  
  private IKeystoreManager sslKeystore;
  
  private long responseLifetime = 60000L; //60sec

  private int LRUsize = 1000;

  /**
   * Cache manager.
   */
  private ICacheManager cacheManager = null;

  /**
   * Constructor.
   */
  public LDAPAuthority() {
  }

  /**
   * Set thread context.
   */
  @Override
  public void setThreadContext(IThreadContext tc)
    throws ManifoldCFException {
    super.setThreadContext(tc);
    cacheManager = CacheManagerFactory.make(tc);
  }

  /**
   * Connect. The configuration parameters are included.
   *
   * @param configParams are the configuration parameters for this connection.
   */
  @Override
  public void connect(ConfigParams configParams) {
    super.connect(configParams);
    //parameters = configParams;

    // Credentials
    bindUser = configParams.getParameter("ldapBindUser");
    bindPass = configParams.getObfuscatedParameter("ldapBindPass");

    // We get the parameters here, so we can check them in case they are missing
    serverProtocol = configParams.getParameter("ldapProtocol");
    serverName = configParams.getParameter("ldapServerName");
    serverPort = configParams.getParameter("ldapServerPort");
    serverBase = configParams.getParameter("ldapServerBase");

    sslKeystoreData = configParams.getParameter("sslKeystore");
    
    userBase = configParams.getParameter("ldapUserBase");
    userSearch = configParams.getParameter("ldapUserSearch");
    groupBase = configParams.getParameter("ldapGroupBase");
    groupSearch = configParams.getParameter("ldapGroupSearch");
    groupNameAttr = configParams.getParameter("ldapGroupNameAttr");
    userNameAttr = configParams.getParameter("ldapUserNameAttr");
    groupMemberDN = "1".equals(getParam(configParams, "ldapGroupMemberDn", ""));
    addUserRecord = "1".equals(getParam(configParams, "ldapAddUserRecord", ""));

    forcedTokens = new ArrayList<String>();
    int i = 0;
    while (i < configParams.getChildCount()) {
      ConfigNode sn = configParams.getChild(i++);
      if (sn.getType().equals("access")) {
        String token = "" + sn.getAttributeValue("token");
        forcedTokens.add(token);
      }
    }
  }

  // All methods below this line will ONLY be called if a connect() call succeeded
  // on this instance!
  /**
   * Session setup. Anything that might need to throw an exception should go
   * here.
   */
  protected LdapContext getSession()
    throws ManifoldCFException {

    try {
      LDAPProtocolEnum ldapProtocol = retrieveLDAPProtocol();
      if (session == null) {
        if (serverName == null || serverName.length() == 0) {
          Logging.authorityConnectors.error("Server name parameter missing but required");
          throw new ManifoldCFException("Server name parameter missing but required");
        }
        if (serverPort == null || serverPort.length() == 0) {
          Logging.authorityConnectors.error("Server port parameter missing but required");
          throw new ManifoldCFException("Server port parameter missing but required");
        }
        if (serverBase == null) {
          Logging.authorityConnectors.error("Server base parameter missing but required");
          throw new ManifoldCFException("Server base parameter missing but required");
        }
        if (userBase == null) {
          Logging.authorityConnectors.error("User base parameter missing but required");
          throw new ManifoldCFException("User base parameter missing but required");
        }
        if (userSearch == null || userSearch.length() == 0) {
          Logging.authorityConnectors.error("User search expression missing but required");
          throw new ManifoldCFException("User search expression missing but required");
        }
        if (groupBase == null) {
          Logging.authorityConnectors.error("Group base parameter missing but required");
          throw new ManifoldCFException("Group base parameter missing but required");
        }
        if (groupSearch == null || groupSearch.length() == 0) {
          Logging.authorityConnectors.error("Group search expression missing but required");
          throw new ManifoldCFException("Group search expression missing but required");
        }
        if (groupNameAttr == null || groupNameAttr.length() == 0) {
          Logging.authorityConnectors.error("Group name attribute missing but required");
          throw new ManifoldCFException("Group name attribute missing but required");
        }
        if (userNameAttr == null || userNameAttr.length() == 0) {
          Logging.authorityConnectors.error("User name attribute missing but required");
          throw new ManifoldCFException("User name attribute missing but required");
        }

        if (sslKeystoreData != null) {
          sslKeystore = KeystoreManagerFactory.make("", sslKeystoreData);
        } else {
          sslKeystore = KeystoreManagerFactory.make("");
        }

        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, "ldap://" + serverName + ":" + serverPort + "/" + serverBase);
        if (LDAPProtocolEnum.LDAPS.equals(ldapProtocol)) {
          // Set thread local for keystore stuff
          LDAPSSLSocketFactory.setSocketFactoryProducer(sslKeystore);
          env.put(Context.SECURITY_PROTOCOL, "ssl");
          env.put("java.naming.ldap.factory.socket", "org.apache.manifoldcf.core.common.LDAPSSLSocketFactory");
        }
        
        if (bindUser != null && !bindUser.isEmpty()) {
          env.put(Context.SECURITY_AUTHENTICATION, "simple");
          env.put(Context.SECURITY_PRINCIPAL, bindUser);
          env.put(Context.SECURITY_CREDENTIALS, bindPass);
        }

        Logging.authorityConnectors.info("LDAP Context environment properties: " + printLdapContextEnvironment(env));
        session = new InitialLdapContext(env, null);
        
        if (isLDAPTLS(ldapProtocol)) {
          // Start TLS
          StartTlsResponse tls = (StartTlsResponse) session.extendedOperation(new StartTlsRequest());
          tls.negotiate(sslKeystore.getSecureSocketFactory());
        }
        
      } else {
        if (isLDAPS(ldapProtocol)) {
          // Set thread local for keystore stuff
          LDAPSSLSocketFactory.setSocketFactoryProducer(sslKeystore);
        }
        session.reconnect(null);
      }
      sessionExpirationTime = System.currentTimeMillis() + 300000L;
      return session;
    } catch (AuthenticationException e) {
      session = null;
      sessionExpirationTime = -1L;
      Logging.authorityConnectors.error("Authentication error: " + e.getMessage() + ", explanation: " + e.getExplanation(), e);
      throw new ManifoldCFException("Authentication error: " + e.getMessage() + ", explanation: " + e.getExplanation(), e);
    } catch (CommunicationException e) {
      session = null;
      sessionExpirationTime = -1L;
      Logging.authorityConnectors.error("Communication error: " + e.getMessage(), e);
      throw new ManifoldCFException("Communication error: " + e.getMessage(), e);
    } catch (NamingException e) {
      session = null;
      sessionExpirationTime = -1L;
      Logging.authorityConnectors.error("Naming exception: " + e.getMessage(), e);
      throw new ManifoldCFException("Naming exception: " + e.getMessage(), e);
    } catch (InterruptedIOException e) {
      session = null;
      sessionExpirationTime = -1L;
      Logging.authorityConnectors.error("Interrupted IO error: " + e.getMessage());
      throw new ManifoldCFException(e.getMessage(), ManifoldCFException.INTERRUPTED);
    } catch (IOException e) {
      session = null;
      sessionExpirationTime = -1L;
      Logging.authorityConnectors.error("IO error: " + e.getMessage(), e);
      throw new ManifoldCFException("IO error: " + e.getMessage(), e);
    }
  }

  /**
   * Retrieves LDAPProtocol from serverProtocol String
   *
   * @return LDAPProtocolEnum
   */
  private LDAPProtocolEnum retrieveLDAPProtocol() {
    if (serverProtocol == null || serverProtocol.length() == 0) {
      return  LDAPProtocolEnum.LDAP;
    }

    final LDAPProtocolEnum ldapProtocol;
    switch (serverProtocol.toUpperCase(Locale.ENGLISH)){
      case "LDAP":
        ldapProtocol = LDAPProtocolEnum.LDAP;
        break;
      case "LDAPS":
        ldapProtocol = LDAPProtocolEnum.LDAPS;
        break;
      case "LDAP+TLS":
        ldapProtocol = LDAPProtocolEnum.LDAP_TLS;
        break;
      case "LDAPS+TLS":
        ldapProtocol = LDAPProtocolEnum.LDAPS_TLS;
        break;
      default:
        ldapProtocol = LDAPProtocolEnum.LDAP;
    }
    return ldapProtocol;
  }

  /**
   * Checks whether TLS is enabled for given LDAP Protocol
   *
   * @param ldapProtocol to check
   * @return whether TLS is enabled or not
   */
  private boolean isLDAPTLS (LDAPProtocolEnum ldapProtocol){
    return LDAPProtocolEnum.LDAP_TLS.equals(ldapProtocol) || LDAPProtocolEnum.LDAPS_TLS.equals(ldapProtocol);
  }

  /**
   * Checks whether LDAPS or LDAPS with TLS is enabled for given LDAP Protocol
   *
   * @param ldapProtocol to check
   * @return whether LDAPS or LDAPS with TLS is enabled or not
   */
  private boolean isLDAPS (LDAPProtocolEnum ldapProtocol){
    return LDAPProtocolEnum.LDAPS.equals(ldapProtocol) || LDAPProtocolEnum.LDAPS_TLS.equals(ldapProtocol);
  }

  /**
   * Check connection for sanity.
   */
  @Override
  public String check()
    throws ManifoldCFException {
    disconnectSession();
    getSession();
    // MHL for a real check of all the search etc.
    return super.check();
  }

  /** This method is called to assess whether to count this connector instance should
  * actually be counted as being connected.
  *@return true if the connector instance is actually connected.
  */
  @Override
  public boolean isConnected()
  {
    return session != null;
  }

  /**
   * Poll. The connection should be closed if it has been idle for too long.
   */
  @Override
  public void poll()
    throws ManifoldCFException {
    if (session != null && System.currentTimeMillis() > sessionExpirationTime) {
      disconnectSession();
    }
    super.poll();
  }

  /**
   * Disconnect a session.
   */
  protected void disconnectSession() {
    if (session != null) {
      try {
        if (tls != null)
          tls.close();
        session.close();
      } catch (NamingException e) {
        // Eat this error
      } catch (IOException e) {
        // Eat this error
      }
      tls = null;
      session = null;
      sessionExpirationTime = -1L;
    }
  }

  /**
   * Close the connection. Call this before discarding the repository connector.
   */
  @Override
  public void disconnect()
    throws ManifoldCFException {
    disconnectSession();
    super.disconnect();
    // Zero out all the stuff that we want to be sure we don't use again
    serverName = null;
    serverPort = null;
    serverBase = null;
    userBase = null;
    userSearch = null;
    groupBase = null;
    groupSearch = null;
    groupNameAttr = null;
    userNameAttr = null;
    forcedTokens = null;
    sslKeystoreData = null;
    sslKeystore = null;
  }

  protected String createCacheConnectionString() {
    StringBuilder sb = new StringBuilder();
    sb.append(serverName).append(":").append(serverPort).append("/").append(serverBase);
    return sb.toString();
  }

  protected String createUserSearchString() {
    StringBuilder sb = new StringBuilder();
    sb.append(userBase).append("|").append(userSearch).append("|").append(userNameAttr).append("|").append(addUserRecord ? 'Y' : 'N');
    return sb.toString();
  }

  protected String createGroupSearchString() {
    StringBuilder sb = new StringBuilder();
    sb.append(groupBase).append("|").append(groupSearch).append("|").append(groupNameAttr).append("|").append(groupMemberDN ? 'Y' : 'N');
    return sb.toString();
  }

  /**
   * Obtain the access tokens for a given user name.
   *
   * @param userName is the user name or identifier.
   * @return the response tokens (according to the current authority). (Should
   * throws an exception only when a condition cannot be properly described
   * within the authorization response object.)
   */
  @Override
  public AuthorizationResponse getAuthorizationResponse(String userName)
    throws ManifoldCFException {

    getSession();
    // Construct a cache description object
    ICacheDescription objectDescription = new LdapAuthorizationResponseDescription(userName,
      createCacheConnectionString(), createUserSearchString(), createGroupSearchString(), this.responseLifetime, this.LRUsize);

    // Enter the cache
    ICacheHandle ch = cacheManager.enterCache(new ICacheDescription[]{objectDescription}, null, null);
    try {
      ICacheCreateHandle createHandle = cacheManager.enterCreateSection(ch);
      try {
        // Lookup the object
        AuthorizationResponse response = (AuthorizationResponse) cacheManager.lookupObject(createHandle, objectDescription);
        if (response != null) {
          return response;
        }
        // Create the object.
        response = getAuthorizationResponseUncached(userName);
        // Save it in the cache
        cacheManager.saveObject(createHandle, objectDescription, response);
        // And return it...
        return response;
      } finally {
        cacheManager.leaveCreateSection(createHandle);
      }
    } finally {
      cacheManager.leaveCache(ch);
    }
  }

  protected AuthorizationResponse getAuthorizationResponseUncached(String userName)
    throws ManifoldCFException {
    getSession();
    try {
      //find user in LDAP tree
      SearchResult usrRecord = getUserEntry(session, userName);
      if (usrRecord == null) {
        return RESPONSE_USERNOTFOUND;
      }

      ArrayList theGroups = new ArrayList();
      theGroups.addAll(forcedTokens);

      String usrName = userName.split("@")[0];
      if (userNameAttr != null && !"".equals(userNameAttr)) {
        if (usrRecord.getAttributes() != null) {
          Attribute attr = usrRecord.getAttributes().get(userNameAttr);
          if (attr != null) {
            usrName = attr.get().toString();
            if (addUserRecord) {
              NamingEnumeration values = attr.getAll();
              while (values.hasMore()) {
                theGroups.add(values.next().toString());
              }
            }
          }
        }
      }

      if (groupSearch != null && !groupSearch.isEmpty()) {
        //specify the LDAP search filter
        String searchFilter = groupSearch.replaceAll("\\{0\\}", escapeLDAPSearchFilter(groupMemberDN ? usrRecord.getNameInNamespace() : usrName));
        SearchControls searchCtls = new SearchControls();
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        String returnedAtts[] = {groupNameAttr};
        searchCtls.setReturningAttributes(returnedAtts);

        NamingEnumeration answer = session.search(groupBase, searchFilter, searchCtls);

        while (answer.hasMoreElements()) {
          SearchResult sr = (SearchResult) answer.next();
          Attributes attrs = sr.getAttributes();
          if (attrs != null) {
            NamingEnumeration values = attrs.get(groupNameAttr).getAll();
            while (values.hasMore()) {
              theGroups.add(values.next().toString());
            }
          }
        }
      }

      String[] tokens = new String[theGroups.size()];
      int k = 0;
      while (k < tokens.length) {
        tokens[k] = (String) theGroups.get(k);
        k++;
      }

      return new AuthorizationResponse(tokens, AuthorizationResponse.RESPONSE_OK);

    } catch (NameNotFoundException e) {
      // This means that the user doesn't exist
      Logging.authorityConnectors.error("User does not exists: "+ e.getMessage(), e);
      return RESPONSE_USERNOTFOUND;
    } catch (NamingException e) {
      // Unreachable
      Logging.authorityConnectors.error("Response Unreachable: "+ e.getMessage(), e);
      return RESPONSE_UNREACHABLE;
    }
  }

  /**
   * Obtain the default access tokens for a given user name.
   *
   * @param userName is the user name or identifier.
   * @return the default response tokens, presuming that the connect method
   * fails.
   */
  @Override
  public AuthorizationResponse getDefaultAuthorizationResponse(String userName) {
    // The default response if the getConnection method fails
    return RESPONSE_UNREACHABLE;
  }

  /**
   * Stringifies LDAP Context environment variable
   * @param env LDAP Context environment variable
   * @return Stringified LDAP Context environment. Password is masked if set.
   */
  private String printLdapContextEnvironment(Hashtable env) {
    Hashtable copyEnv = new Hashtable<>(env);
    if (copyEnv.containsKey(Context.SECURITY_CREDENTIALS)){
      copyEnv.put(Context.SECURITY_CREDENTIALS, "********");
    }
    return Arrays.toString(copyEnv.entrySet().toArray());
  }

  // UI support methods.
  //
  // These support methods are involved in setting up authority connection configuration information. The configuration methods cannot assume that the
  // current authority object is connected.  That is why they receive a thread context argument.
  /**
   * Output the configuration header section. This method is called in the head
   * section of the connector's configuration page. Its purpose is to add the
   * required tabs to the list, and to output any javascript methods that might
   * be needed by the configuration editing HTML.
   *
   * @param threadContext is the local thread context.
   * @param out is the output to which any HTML should be sent.
   * @param parameters are the configuration parameters, as they currently
   * exist, for this connection being configured.
   * @param tabsArray is an array of tab names. Add to this array any tab names
   * that are specific to the connector.
   */
  @Override
  public void outputConfigurationHeader(IThreadContext threadContext, IHTTPOutput out, Locale locale, ConfigParams parameters, List<String> tabsArray)
    throws ManifoldCFException, IOException {
    tabsArray.add(Messages.getString(locale, "LDAP.LDAP"));
    tabsArray.add(Messages.getString(locale, "LDAP.ForcedTokens"));
    final Map<String,Object> paramMap = new HashMap<String,Object>();
    fillInLDAPTab(paramMap, out, parameters);
    fillInForcedTokensTab(paramMap, out, parameters);
    Messages.outputResourceWithVelocity(out, locale, "editConfiguration.js", paramMap);    
  }

  /**
   * Output the configuration body section. This method is called in the body
   * section of the authority connector's configuration page. Its purpose is to
   * present the required form elements for editing. The coder can presume that
   * the HTML that is output from this configuration will be within appropriate
   * &lt;html&gt;, &lt;body&gt;, and &lt;form&gt; tags. The name of the form is "editconnection".
   *
   * @param threadContext is the local thread context.
   * @param out is the output to which any HTML should be sent.
   * @param parameters are the configuration parameters, as they currently
   * exist, for this connection being configured.
   * @param tabName is the current tab name.
   */
  @Override
  public void outputConfigurationBody(IThreadContext threadContext, IHTTPOutput out, Locale locale, ConfigParams parameters, String tabName)
    throws ManifoldCFException, IOException {
    final Map<String,Object> paramMap = new HashMap<String,Object>();
    paramMap.put("TabName",tabName);
    fillInLDAPTab(paramMap, out, parameters);
    fillInForcedTokensTab(paramMap, out, parameters);
    Messages.outputResourceWithVelocity(out, locale, "editConfiguration_LDAP.html", paramMap);    
    Messages.outputResourceWithVelocity(out, locale, "editConfiguration_ForcedTokens.html", paramMap);    
  }

  private boolean copyParam(IPostParameters variableContext, ConfigParams parameters, String name) {
    String val = variableContext.getParameter(name);
    if (val == null) {
      return false;
    }
    parameters.setParameter(name, val);
    return true;
  }

  private void copyParam(IPostParameters variableContext, ConfigParams parameters, String name, String def) {
    String val = variableContext.getParameter(name);
    if (val == null) {
      val = def;
    }
    parameters.setParameter(name, val);
  }

  /**
   * Process a configuration post. This method is called at the start of the
   * authority connector's configuration page, whenever there is a possibility
   * that form data for a connection has been posted. Its purpose is to gather
   * form information and modify the configuration parameters accordingly. The
   * name of the posted form is "editconnection".
   *
   * @param threadContext is the local thread context.
   * @param variableContext is the set of variables available from the post,
   * including binary file post information.
   * @param parameters are the configuration parameters, as they currently
   * exist, for this connection being configured.
   * @return null if all is well, or a string error message if there is an error
   * that should prevent saving of the connection (and cause a redirection to an
   * error page).
   */
  @Override
  public String processConfigurationPost(IThreadContext threadContext, IPostParameters variableContext, Locale locale, ConfigParams parameters)
    throws ManifoldCFException {
    copyParam(variableContext, parameters, "ldapProtocol");
    copyParam(variableContext, parameters, "ldapServerName");
    copyParam(variableContext, parameters, "ldapServerPort");
    copyParam(variableContext, parameters, "ldapServerBase");
    copyParam(variableContext, parameters, "ldapUserBase");
    copyParam(variableContext, parameters, "ldapUserSearch");
    copyParam(variableContext, parameters, "ldapUserNameAttr");
    copyParam(variableContext, parameters, "ldapGroupBase");
    copyParam(variableContext, parameters, "ldapGroupSearch");
    copyParam(variableContext, parameters, "ldapGroupNameAttr");

    copyParam(variableContext, parameters, "ldapGroupMemberDn", "0"); //checkbox boolean value
    copyParam(variableContext, parameters, "ldapAddUserRecord", "0"); //checkbox boolean value

    copyParam(variableContext, parameters, "ldapBindUser");
    final String bindPass = variableContext.getParameter("ldapBindPass");
    if (bindPass != null) {
      parameters.setObfuscatedParameter("ldapBindPass", variableContext.mapKeyToPassword(bindPass));
    }

    final String xc = variableContext.getParameter("tokencount");
    if (xc != null) {
      // Delete all tokens first
      int i = 0;
      while (i < parameters.getChildCount()) {
        ConfigNode sn = parameters.getChild(i);
        if (sn.getType().equals("access")) {
          parameters.removeChild(i);
        } else {
          i++;
        }
      }

      final int accessCount = Integer.parseInt(xc);
      i = 0;
      while (i < accessCount) {
        final String accessDescription = "_" + Integer.toString(i);
        final String accessOpName = "accessop" + accessDescription;
        final String command = variableContext.getParameter(accessOpName);
        if (command != null && command.equals("Delete")) {
          // Next row
          i++;
          continue;
        }
        // Get the stuff we need
        String accessSpec = variableContext.getParameter("spectoken" + accessDescription);
        ConfigNode node = new ConfigNode("access");
        node.setAttribute("token", accessSpec);
        parameters.addChild(parameters.getChildCount(), node);
        i++;
      }

      String op = variableContext.getParameter("accessop");
      if (op != null && op.equals("Add")) {
        String accessspec = variableContext.getParameter("spectoken");
        ConfigNode node = new ConfigNode("access");
        node.setAttribute("token", accessspec);
        parameters.addChild(parameters.getChildCount(), node);
      }
    }

    String sslKeystoreValue = variableContext.getParameter("sslkeystoredata");
    final String sslConfigOp = variableContext.getParameter("sslconfigop");
    if (sslConfigOp != null)
    {
      if (sslConfigOp.equals("Delete"))
      {
        final String alias = variableContext.getParameter("sslkeystorealias");
        final IKeystoreManager mgr;
        if (sslKeystoreValue != null)
          mgr = KeystoreManagerFactory.make("",sslKeystoreValue);
        else
          mgr = KeystoreManagerFactory.make("");
        mgr.remove(alias);
        sslKeystoreValue = mgr.getString();
      }
      else if (sslConfigOp.equals("Add"))
      {
        String alias = IDFactory.make(threadContext);
        byte[] certificateValue = variableContext.getBinaryBytes("sslcertificate");
        final IKeystoreManager mgr;
        if (sslKeystoreValue != null)
          mgr = KeystoreManagerFactory.make("",sslKeystoreValue);
        else
          mgr = KeystoreManagerFactory.make("");
        java.io.InputStream is = new java.io.ByteArrayInputStream(certificateValue);
        String certError = null;
        try
        {
          mgr.importCertificate(alias,is);
        }
        catch (Throwable e)
        {
          certError = e.getMessage();
        }
        finally
        {
          try
          {
            is.close();
          }
          catch (IOException e)
          {
            // Eat this exception
          }
        }

        if (certError != null)
        {
          return "Illegal certificate: "+certError;
        }
        sslKeystoreValue = mgr.getString();
      }
    }
    if (sslKeystoreValue != null)
      parameters.setParameter("sslkeystore",sslKeystoreValue);
    
    return null;
  }

  /**
   * View configuration. This method is called in the body section of the
   * authority connector's view configuration page. Its purpose is to present
   * the connection information to the user. The coder can presume that the HTML
   * that is output from this configuration will be within appropriate &lt;html&gt;
   * and &lt;body&gt; tags.
   *
   * @param threadContext is the local thread context.
   * @param out is the output to which any HTML should be sent.
   * @param parameters are the configuration parameters, as they currently
   * exist, for this connection being configured.
   */
  @Override
  public void viewConfiguration(IThreadContext threadContext, IHTTPOutput out, Locale locale, ConfigParams parameters)
    throws ManifoldCFException, IOException {
    final Map<String,Object> paramMap = new HashMap<String,Object>();
    fillInLDAPTab(paramMap, out, parameters);
    fillInForcedTokensTab(paramMap, out, parameters);
    Messages.outputResourceWithVelocity(out, locale, "viewConfiguration.html", paramMap);    
  }

  // Protected methods
  
  private static String getParam(final ConfigParams parameters, final String name, final String def) {
    String rval = parameters.getParameter(name);
    return rval != null ? rval : def;
  }

  /** Fill in ForcedTokens tab */
  protected static void fillInForcedTokensTab(Map<String,Object> velocityContext, IHTTPOutput out, ConfigParams parameters)
  {
    final List<String> forcedTokenList = new ArrayList<String>();
    for (int i = 0; i < parameters.getChildCount(); i++) {
      final ConfigNode sn = parameters.getChild(i);
      if (sn.getType().equals("access")) {
        forcedTokenList.add(sn.getAttributeValue("token"));
      }
    }
    velocityContext.put("FORCEDTOKENS", forcedTokenList);
  }
  
  /** Fill in LDAP tab */
  protected static void fillInLDAPTab(Map<String,Object> velocityContext, IHTTPOutput out, ConfigParams parameters)
  {
    velocityContext.put("FSERVERPROTOCOL", getParam(parameters, "ldapProtocol", "ldap"));
    velocityContext.put("FSERVERNAME", getParam(parameters, "ldapServerName", ""));
    velocityContext.put("FSERVERPORT", getParam(parameters, "ldapServerPort", "389"));
    velocityContext.put("FSERVERBASE", getParam(parameters, "ldapServerBase", ""));
    String sslKeystoreData = parameters.getParameter("sslkeystore");
    if (sslKeystoreData != null)
      velocityContext.put("SSLKEYSTOREDATA", sslKeystoreData);
    velocityContext.put("FUSERBASE", getParam(parameters, "ldapUserBase", "ou=People"));
    velocityContext.put("FUSERSEARCH", getParam(parameters, "ldapUserSearch", "(&(objectClass=inetOrgPerson)(uid={0}))"));
    velocityContext.put("FUSERNAMEATTR", getParam(parameters, "ldapUserNameAttr", "uid"));
    velocityContext.put("FADDUSERRECORD", getParam(parameters, "ldapAddUserRecord", ""));
    velocityContext.put("FGROUPBASE", getParam(parameters, "ldapGroupBase", "ou=Groups"));
    velocityContext.put("FGROUPSEARCH", getParam(parameters, "ldapGroupSearch", "(&(objectClass=groupOfNames)(member={0}))"));
    velocityContext.put("FGROUPNAMEATTR", getParam(parameters, "ldapGroupNameAttr", "cn"));
    velocityContext.put("FGROUPMEMBERDN", getParam(parameters, "ldapGroupMemberDn", ""));
    velocityContext.put("FBINDUSER", getParam(parameters, "ldapBindUser", ""));
    String fBindPass = parameters.getObfuscatedParameter("ldapBindPass");
    if (fBindPass == null)
      fBindPass = "";
    else
      fBindPass = out.mapPasswordToKey(fBindPass);
    velocityContext.put("FBINDPASS", fBindPass);
    
    Map<String,String> sslCertificatesMap = null;
    String message = null;

    try {
      final IKeystoreManager localSslKeystore;
      if (sslKeystoreData == null)
        localSslKeystore = KeystoreManagerFactory.make("");
      else
        localSslKeystore = KeystoreManagerFactory.make("",sslKeystoreData);

      // List the individual certificates in the store, with a delete button for each
      final String[] contents = localSslKeystore.getContents();
      if (contents.length > 0)
      {
        sslCertificatesMap = new HashMap<>();
        for (final String alias : contents)
        {
          String description = localSslKeystore.getDescription(alias);
          if (description.length() > 128)
            description = description.substring(0,125) + "...";
          sslCertificatesMap.put(alias, description);
        }
      }
    } catch (ManifoldCFException e) {
      message = e.getMessage();
      Logging.authorityConnectors.warn(e);
    }

    if(sslCertificatesMap != null)
      velocityContext.put("SSLCERTIFICATESMAP", sslCertificatesMap);
    if(message != null)
      velocityContext.put("MESSAGE", message);
  }

  /**
   * Obtain the user LDAP record for a given user logon name.
   *
   * @param ctx is the ldap context to use.
   * @param userName (Domain Logon Name) is the user name or identifier.
   * DC=qa-ad-76,DC=metacarta,DC=com)
   * @return SearchResult for given domain user logon name. (Should throws an
   * exception if user is not found.)
   */
  protected SearchResult getUserEntry(LdapContext ctx, String userName)
    throws ManifoldCFException {
    String searchFilter = userSearch.replaceAll("\\{0\\}", escapeDN(userName.split("@")[0]));
    SearchControls searchCtls = new SearchControls();
    searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

    try {
      NamingEnumeration answer = ctx.search(userBase, searchFilter, searchCtls);
      if (answer.hasMoreElements()) {
        return (SearchResult) answer.next();
      }
      return null;
    } catch (Exception e) {
      throw new ManifoldCFException(e.getMessage(), e);
    }
  }

  /**
   * LDAP escape a string.
   */
  protected static String ldapEscape(String input) {
    //Add escape sequence to all commas
    StringBuilder sb = new StringBuilder();
    int index = 0;
    while (true) {
      int oldIndex = index;
      index = input.indexOf(",", oldIndex);
      if (index == -1) {
        sb.append(input.substring(oldIndex));
        break;
      }
      sb.append(input.substring(oldIndex, index)).append("\\,");
      index++;
    }
    return sb.toString();
  }

  public static String escapeDN(String name) {
    StringBuilder sb = new StringBuilder(); // If using JDK >= 1.5 consider using StringBuilder
    if ((name.length() > 0) && ((name.charAt(0) == ' ') || (name.charAt(0) == '#'))) {
      sb.append('\\'); // add the leading backslash if needed
    }
    for (int i = 0; i < name.length(); i++) {
      char curChar = name.charAt(i);
      switch (curChar) {
        case '\\':
          sb.append("\\\\");
          break;
        case ',':
          sb.append("\\,");
          break;
        case '+':
          sb.append("\\+");
          break;
        case '"':
          sb.append("\\\"");
          break;
        case '<':
          sb.append("\\<");
          break;
        case '>':
          sb.append("\\>");
          break;
        case ';':
          sb.append("\\;");
          break;
        default:
          sb.append(curChar);
      }
    }
    if ((name.length() > 1) && (name.charAt(name.length() - 1) == ' ')) {
      sb.insert(sb.length() - 1, '\\'); // add the trailing backslash if needed
    }
    return sb.toString();
  }

  public static String escapeLDAPSearchFilter(String filter) {
    StringBuilder sb = new StringBuilder(); // If using JDK >= 1.5 consider using StringBuilder
    for (int i = 0; i < filter.length(); i++) {
      char curChar = filter.charAt(i);
      switch (curChar) {
        case '\\':
          sb.append("\\5c");
          break;
        case '*':
          sb.append("\\2a");
          break;
        case '(':
          sb.append("\\28");
          break;
        case ')':
          sb.append("\\29");
          break;
        case '\u0000':
          sb.append("\\00");
          break;
        default:
          sb.append(curChar);
      }
    }
    return sb.toString();
  }

  protected static StringSet emptyStringSet = new StringSet();

  /**
   * This is the cache object descriptor for cached access tokens from this
   * connector.
   */
  protected class LdapAuthorizationResponseDescription extends org.apache.manifoldcf.core.cachemanager.BaseDescription {

    /**
     * The user name
     */
    protected String userName;

    /**
     * LDAP connection string with server name and base DN
     */
    protected String connectionString;

    /**
     * User search definition
     */
    protected String userSearch;

    /**
     * Group search definition
     */
    protected String groupSearch;

    /**
     * The response lifetime
     */
    protected long responseLifetime;

    /**
     * The expiration time
     */
    protected long expirationTime = -1;

    /**
     * Constructor.
     */
    public LdapAuthorizationResponseDescription(String userName, String connectionString, String userSearch, String groupSearch, long responseLifetime, int LRUsize) {
      super("LDAPAuthority", LRUsize);
      this.userName = userName;
      this.connectionString = connectionString;
      this.userSearch = userSearch;
      this.groupSearch = groupSearch;
      this.responseLifetime = responseLifetime;
    }

    /**
     * Return the invalidation keys for this object.
     */
    public StringSet getObjectKeys() {
      return emptyStringSet;
    }

    /**
     * Get the critical section name, used for synchronizing the creation of the
     * object
     */
    public String getCriticalSectionName() {
      StringBuilder sb = new StringBuilder(getClass().getName());
      sb.append("-").append(userName).append("-").append(connectionString);
      return sb.toString();
    }

    /**
     * Return the object expiration interval
     */
    @Override
    public long getObjectExpirationTime(long currentTime) {
      if (expirationTime == -1) {
        expirationTime = currentTime + responseLifetime;
      }
      return expirationTime;
    }

    @Override
    public int hashCode() {
      return userName.hashCode() + connectionString.hashCode() + userSearch.hashCode() + groupSearch.hashCode();
    }

    @Override
    public boolean equals(Object o) {
      if (!(o instanceof LdapAuthorizationResponseDescription)) {
        return false;
      }
      LdapAuthorizationResponseDescription ard = (LdapAuthorizationResponseDescription) o;
      if (!ard.userName.equals(userName)) {
        return false;
      }
      if (!ard.connectionString.equals(connectionString)) {
        return false;
      }
      if (!ard.userSearch.equals(userSearch)) {
        return false;
      }
      if (!ard.groupSearch.equals(groupSearch)) {
        return false;
      }
      return true;
    }
  }
}
