/*
 * LDAPSecurityManager.java
 *
 * Created on January 29, 2006, 8:22 PM
 *
 * (C) R. Alexander Milowski alex@milowski.com
 */

package org.exist.security;

import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;

import javax.naming.Context;
import javax.naming.NameNotFoundException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;

import org.apache.log4j.Logger;
import org.exist.security.xacml.ExistPDP;
import org.exist.storage.BrokerPool;
import org.exist.storage.DBBroker;

/**
 * Note: A lot of this code is "borrowed" from Tomcat's JNDIRealm.java
 * @author R. Alexander Milowski
 */
public class LDAPSecurityManager implements SecurityManager
{

   private final static Logger LOG = Logger.getLogger(SecurityManager.class);
   protected Map userByNameCache = new HashMap();
   protected Map userByIdCache = new HashMap();
   protected Map groupByNameCache = new HashMap();
   protected Map groupByIdCache = new HashMap();
   
   static String getProperty(String name,String defaultValue) {
      String value = System.getProperty(name);
      return value==null ? defaultValue : value;
   }
   
   protected String contextFactory = getProperty("security.ldap.contextFactory","com.sun.jndi.ldap.LdapCtxFactory");

   protected String connectionURL = getProperty("security.ldap.connection.url",null);
   
   protected String userPasswordAttr = getProperty("security.ldap.attr.userPassword", "userPassword");
   protected String userDigestPasswordAttr = getProperty("security.ldap.attr.userDigestPassword", "digestPassword");
   protected String uidAttr = getProperty("security.ldap.attr.uid", "uid");
   protected String uidNumberAttr = getProperty("security.ldap.attr.uidNumber", "uidNumber");
   protected String gidNumberAttr = getProperty("security.ldap.attr.gidNumber", "gidNumber");
   protected String groupNameAttr = getProperty("security.ldap.attr.groupName", "cn");
   protected String groupMemberName = getProperty("security.ldap.attr.groupMemberName", "uniqueMember");
   protected String groupClassName = getProperty("security.ldap.groupClass", "posixGroup");
   protected String userClassName = getProperty("security.ldap.userClass", "posixAccount");
   
   protected String userBase = getProperty("security.ldap.dn.user", null);
   protected String groupBase = getProperty("security.ldap.dn.group", null);
   
   protected DirContext context = null;
   
   
   /**
    * The message format used to form the distinguished name of a
    * user, with "{0}" marking the spot where the specified username
    * goes.
    */
   protected String userByNamePattern = null;
   protected String userByIdPattern = null;
   protected MessageFormat userByNamePatternFormat = null;
   protected MessageFormat userByIdPatternFormat = null;
   protected String groupByIdPattern = null;
   protected String groupByNamePattern = null;
   protected MessageFormat groupByIdPatternFormat = null;
   protected MessageFormat groupByNamePatternFormat = null;
   
   protected ExistPDP pdp = null;
   
   /** Creates a new instance of LDAPSecurityManager */
   public LDAPSecurityManager()
   {
      setUserByNamePattern(uidAttr+"={0},"+userBase);
      setUserByIdPattern(uidNumberAttr+"={0},"+userBase);
      setGroupByIdPattern(gidNumberAttr+"={0},"+groupBase);
      setGroupByNamePattern(groupNameAttr+"={0},"+groupBase);
   }

   /**
    * Set the message format pattern for selecting users in this Realm.
    * This may be one simple pattern, or multiple patterns to be tried,
    * separated by parentheses. (for example, either "cn={0}", or
    * "(cn={0})(cn={0},o=myorg)" Full LDAP search strings are also supported,
    * but only the "OR", "|" syntax, so "(|(cn={0})(cn={0},o=myorg))" is
    * also valid. Complex search strings with &, etc are NOT supported.
    *
    * @param pattern The new user pattern
    */
   public void setUserByNamePattern(String pattern)
   {
      
      this.userByNamePattern = pattern;
      this.userByNamePatternFormat = new MessageFormat(userByNamePattern);
   }
    
   public void setUserByIdPattern(String pattern)
   {
      
      this.userByIdPattern = pattern;
      this.userByIdPatternFormat = new MessageFormat(userByIdPattern);
   }
    
   public void setGroupByIdPattern(String pattern)
   {
      
      this.groupByIdPattern = pattern;
      this.groupByIdPatternFormat = new MessageFormat(groupByIdPattern);
   }
    
   public void setGroupByNamePattern(String pattern)
   {
      
      this.groupByNamePattern = pattern;
      this.groupByNamePatternFormat = new MessageFormat(groupByNamePattern);
   }
    
   /**
    * Return a String representing the value of the specified attribute.
    *
    * @param attrId Attribute name
    * @param attrs Attributes containing the required value
    *
    * @exception NamingException if a directory server error occurs
    */
   private String getAttributeValue(String attrId, Attributes attrs)
      throws NamingException 
   {
      
      if (attrId == null || attrs == null) {
         return null;
      }
      
      Attribute attr = attrs.get(attrId);
      if (attr == null) {
         return (null);
      }
      Object value = attr.get();
      if (value == null) {
         return (null);
      }
      String valueString = null;
      if (value instanceof byte[]) {
         valueString = new String((byte[]) value);
      } else {
         valueString = value.toString();
      }
      
      return valueString;
   }

   protected Hashtable getDirectoryEnvironment() {

      if (connectionURL==null) {
         throw new IllegalStateException("The security.ldap.connection.url property is not set.");
      }
      if (userBase==null) {
         throw new IllegalStateException("The security.ldap.dn.user property is not set.");
      }
      if (groupBase==null) {
         throw new IllegalStateException("The security.ldap.dn.group property is not set.");
      }
      Hashtable env = new Hashtable();

      LOG.info("security.ldap.contextFactory="+contextFactory);
      env.put(Context.INITIAL_CONTEXT_FACTORY, contextFactory);
      LOG.info("security.ldap.connection.url="+connectionURL);
      env.put(Context.PROVIDER_URL, connectionURL);
      return env;

   }
   
   // TODO: need an exception to throw
   public void attach(BrokerPool pool, DBBroker sysBroker)
   {
      try {
         context = new InitialDirContext(getDirectoryEnvironment());
         Boolean enableXACML = (Boolean)sysBroker.getConfiguration().getProperty("xacml.enable");
         if (enableXACML != null && enableXACML.booleanValue()) {
            pdp = new ExistPDP(pool);
            LOG.debug("XACML enabled");
         }
      } catch (NamingException ex) {
         LOG.warn("Connecting to context failed for LDAP-based security: "+connectionURL,ex);
      }
   }

   protected User getUserByName(DirContext context, String username)
      throws NamingException
   {
      // Form the dn from the user pattern
      String dn = userByNamePatternFormat.format(new String[] { username });

      LOG.info("Attempting to get user by: "+dn);

      return getUser(context,dn);
   }
      
   protected User getUserById(DirContext context, int uid)
      throws NamingException
   {
      LOG.info("Searching for "+uidNumberAttr+"="+uid+" in "+userBase);
      SearchControls constraints = new SearchControls();

      constraints.setSearchScope(SearchControls.ONELEVEL_SCOPE);
      NamingEnumeration users = context.search(userBase,"("+uidNumberAttr+"="+uid+")",constraints);
      while (users.hasMore()) {
         SearchResult result = (SearchResult)users.next();
         return newUserFromAttributes(context, result.getAttributes());
      }
      return null;
   }
   
   protected Group getGroupById(DirContext context,int gid) 
      throws NamingException
   {
      LOG.info("Searching for "+gidNumberAttr+"="+gid+" in "+groupBase);
      SearchControls constraints = new SearchControls();
      constraints.setSearchScope(SearchControls.ONELEVEL_SCOPE);
      NamingEnumeration groups = context.search(groupBase,"("+gidNumberAttr+"="+gid+")",constraints);
      while (groups.hasMore()) {
         SearchResult result = (SearchResult)groups.next();
         String cn = getAttributeValue(groupNameAttr, result.getAttributes());
         LOG.info("Constructing group "+cn);
         return new Group(cn, gid);
      }
      return null;
   }
   
   protected Group getGroupByName(DirContext context,String name)
      throws NamingException
   {
      String g_dn = groupByNamePatternFormat.format(new String[] { name });
      
      LOG.info("Attempting to get group by: "+g_dn);

      try {
         Attributes attrs = context.getAttributes(g_dn);
         String cn = getAttributeValue(groupNameAttr, attrs);
         int gid = Integer.parseInt(getAttributeValue(gidNumberAttr, attrs));
         return new Group(cn, gid);
      } catch (NameNotFoundException e) {
      }
      return null;
   }
   
   protected User newUserFromAttributes(DirContext context,Attributes attrs)
      throws NamingException
   {
      String username = getAttributeValue(uidAttr,attrs);
      String password = getAttributeValue(userPasswordAttr, attrs);
      String digestPassword = getAttributeValue(userDigestPasswordAttr, attrs);
      String gid = getAttributeValue(gidNumberAttr, attrs);
      String g_dn = groupByIdPatternFormat.format(new String[] { gid });
      
      LOG.info("Searching for "+gidNumberAttr+"="+gid+" in "+groupBase);
      String mainGroup = null;
      SearchControls constraints = new SearchControls();

      constraints.setSearchScope(SearchControls.ONELEVEL_SCOPE);
      NamingEnumeration groups = context.search(groupBase,"("+gidNumberAttr+"="+gid+")",constraints);
      while (mainGroup==null && groups.hasMore()) {
         SearchResult result = (SearchResult)groups.next();
         mainGroup = getAttributeValue(groupNameAttr, result.getAttributes());
      }
      
      if (mainGroup==null || mainGroup.length()==0) {
         throw new IllegalStateException("Main group "+gid+" for user "+username+" is not able to be found in LDAP for group property "+gidNumberAttr);
      }

      int uid = Integer.parseInt(getAttributeValue(uidNumberAttr, attrs));
      LOG.info("Constructing user "+username+"/"+uid+" in group "+(mainGroup==null ? "<none>" : mainGroup));
      User user = new User(username, null, mainGroup);
      user.setUID(uid);
      if (password!=null) {
         if (password.charAt(0)=='{') {
            int end = password.indexOf('}');
            String type = password.substring(0,end+1);
            String value = password.substring(end+1);
            LOG.info("  digest: "+type+", "+value);
            if (!type.equals("{MD5}")) {
               throw new IllegalStateException("User "+username+" has a non-md5 digested password: "+type);
            }
            user.setEncodedPassword(value);
         } else {
            user.setPassword(password);
         }
      }
      if (digestPassword!=null) {
         user.setPasswordDigest(digestPassword);
      }
      
      LOG.info("Finding additional groups...");
      String fullName = uidAttr+"="+username+","+userBase;
      groups = context.search(groupBase,"("+groupMemberName+"="+fullName+")",constraints);
      while (groups.hasMore()) {
         SearchResult result = (SearchResult)groups.next();
         String name = getAttributeValue(groupNameAttr, result.getAttributes());
         if (name==null || name.length()==0) {
            throw new IllegalStateException("Group associated with "+username+" does not have a valid name for attribute "+groupNameAttr);
         }
         if (!name.equals(mainGroup)) {
            LOG.info("   ...adding: "+name);
            user.addGroup(name);
         }
      }
      return user;
   }
      
   protected User getUser(DirContext context, String dn)
      throws NamingException
   {
      
      // Get required attributes from user entry
      Attributes attrs = null;
      try {
         attrs = context.getAttributes(dn);
      } catch (NameNotFoundException ex) {
         LOG.warn("Cannot find user "+dn,ex);
         return (null);
      }
      if (attrs == null) {
         return (null);
      }
      
      LOG.info("User "+dn+" found, attempting to find group and construct...");
      
      return newUserFromAttributes(context,attrs);

   }
   
   
   public void addGroup(String name)
   {
      
   }

   public void deleteUser(String name) throws PermissionDeniedException
   {
   }

   public void deleteUser(User user) throws PermissionDeniedException
   {
   }

   public int getCollectionDefaultPerms()
   {
      return Permission.DEFAULT_PERM;
   }

   public Group getGroup(int gid)
   {
      Integer igid = new Integer(gid);
      Group group = (Group)groupByIdCache.get(igid);
      if (group==null) {
         try {
            group = getGroupById(context,gid);
            if (group!=null) {
               groupByIdCache.put(igid,group);
            }
         } catch (NamingException ex) {
            LOG.warn("Cannot get group by #"+gid+" due to exception.",ex);
         }
      }
      return group;
   }

   public Group getGroup(String name)
   {
      Group group = (Group)groupByIdCache.get(name);
      if (group==null) {
         try {
            group = getGroupByName(context,name);
            if (group!=null) {
               groupByNameCache.put(name,group);
            }
         } catch (NamingException ex) {
            LOG.warn("Cannot get group "+name+" due to exception.",ex);
         }
      }
      return group;
   }

   // This needs to be an enumeration
   public String[] getGroups()
   {
      try {
         SearchControls constraints = new SearchControls();

         constraints.setSearchScope(SearchControls.ONELEVEL_SCOPE);
         NamingEnumeration groups = context.search(groupBase,"(objectClass="+groupClassName+")",constraints);
         List groupList = new ArrayList();
         while (groups.hasMore()) {
            SearchResult result = (SearchResult)groups.next();
            groupList.add(getAttributeValue(groupNameAttr, result.getAttributes()));
         }
         String [] retval = new String[groupList.size()];
         System.arraycopy(groupList.toArray(), 0, retval,0, retval.length);
         return retval;
      } catch (NamingException ex) {
         LOG.warn("Cannot get a list of all groups due to exception.",ex);
      }
      return null;
         
   }

   public boolean isXACMLEnabled() {
      return pdp!=null;
   }
   
   public ExistPDP getPDP()
   {
      return pdp;
   }

   public int getResourceDefaultPerms()
   {
      return Permission.DEFAULT_PERM;
   }

   public User getUser(int uid)
   {
      Integer iuid = new Integer(uid);
      User user = (User)userByIdCache.get(iuid);
      if (user==null) {
         try {
            user = getUserById(context,uid);
            if (user!=null) {
               userByIdCache.put(iuid,user);
            }
         } catch (NamingException ex) {
            LOG.warn("Cannot get user by #"+uid+" due to exception.",ex);
         }
      }
      return user;
   }

   public User getUser(String name)
   {
      User user = (User)userByNameCache.get(name);
      if (user==null) {
         try {
            user = getUserByName(context,name);
            if (user!=null) {
               userByNameCache.put(name,user);
            }
         } catch (NamingException ex) {
            LOG.warn("Cannot get user "+name+" due to exception.",ex);
         }
      }
      return user;
   }

   // This needs to be an enumeration
   public User[] getUsers()
   {
      try {
         SearchControls constraints = new SearchControls();

         constraints.setSearchScope(SearchControls.ONELEVEL_SCOPE);
         NamingEnumeration users = context.search(userBase,"(objectClass="+userClassName+")",constraints);
         List userList = new ArrayList();
         while (users.hasMore()) {
            SearchResult result = (SearchResult)users.next();
            userList.add(newUserFromAttributes(context,result.getAttributes()));
         }
         User [] retval = new User[userList.size()];
         System.arraycopy(userList.toArray(), 0, retval,0, retval.length);
         return retval;
      } catch (NamingException ex) {
         LOG.warn("Cannot get the list of users due to exception.",ex);
      }
      return null;
   }

   // TODO: this shouldn't be in this interface
   public synchronized boolean hasAdminPrivileges(User user) {
      return user.hasDbaRole();
   }
   
   // TODO: why is this here?
   public synchronized boolean hasUser(String name) {
      try {
         return getUserByName(context,name)!=null;
      } catch (NamingException ex) {
         LOG.warn("Cannot check for user "+name+" due to exception",ex);
      }
      return false;
   }

   // TODO: why is this here?
   public synchronized boolean hasGroup(String name) {
      try {
         return getGroupByName(context,name)!=null;
      } catch (NamingException ex) {
         LOG.warn("Cannot check for group "+name+" due to exception",ex);
      }
      return false;
   }

   // TODO: this should be addUser
   public void setUser(User user)
   {
   }
   
}
