/*
 * Copyright (c) 2004-2017, by Alex Leigh.
 * All rights reserved.
 *
 * THIS IS UNPUBLISHED PROPRIETARY SOURCE CODE
 * The copyright notice above does not evidence any
 * actual or intended publication of such source code.
 */

package lc.mecha.aaa;

import lc.mecha.log.MechaLogger;
import lc.mecha.log.MechaLoggerFactory;
import org.apache.commons.lang.StringUtils;

import javax.naming.Context;
import javax.naming.NameAlreadyBoundException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.*;
import javax.naming.ldap.*;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;

/**
 * A low-level API for interacting with a Microsoft ActiveDirectory server.
 * <p>
 * If passwords are to be changed, the URL provided must be SSL encrypted (ldaps), and the info
 * user must have authority to change passwords (either delegated on the OUs in question, or, a domain
 * admin). Additionally before ldaps URLs can be used the server certificate must be added to the
 * trustStore for this VM instance.
 * <p>
 * The <code>InstallCert</code> tool can be used to quickly create a Java Keystore which can be used
 * to trust the remote AD server.
 *
 * @author Alex Leigh
 */
// FIXME: This class needs to escape user-provided strings
// FIXME: This class leaks LdapContexts left and right
public final class ActiveDirectory {
    private final static int MASK_8_BIT = 0xff;
    private final static long MASK_32_BIT = 0xffffffffL;
    private final static long MASK_48_BIT = 0xffffffffffffL;

    public static final String KEY_DN = "distinguishedName";
    public static final String KEY_ACCOUNT = "sAMAccountName";
    public static final String KEY_TOKENGROUPS = "tokenGroups";
    public static final String KEY_OBJECTSID = "objectSid";
    public static final String KEY_OBJECTGUID = "objectGUID";
    public static final String KEY_UPN = "userPrincipalName";
    public static final String KEY_DISPLAYNAME = "displayName";
    public static final String KEY_CN = "cn";

    int UF_ACCOUNTENABLE = 0x0001;
    //int UF_ACCOUNTDISABLE = 0x0002;
    int UF_PASSWD_NOTREQD = 0x0020;
    int UF_PASSWD_CANT_CHANGE = 0x0040;
    int UF_NORMAL_ACCOUNT = 0x0200;
    int UF_DONT_EXPIRE_PASSWD = 0x10000;
    //int UF_PASSWORD_EXPIRED = 0x800000;

    int ADS_GROUP_TYPE_GLOBAL_GROUP = 0x0002;
    int ADS_GROUP_TYPE_DOMAIN_LOCAL_GROUP = 0x0004;
    int ADS_GROUP_TYPE_LOCAL_GROUP = 0x0004;
    int ADS_GROUP_TYPE_UNIVERSAL_GROUP = 0x0008;
    int ADS_GROUP_TYPE_SECURITY_ENABLED = 0x80000000;

    private static final MechaLogger logger = MechaLoggerFactory.getLogger(ActiveDirectory.class);
    private final String domain;
    private final String ldapHost;
    private final String searchBase;
    private String infoUser;
    private String infoPassword;

    public ActiveDirectory(ActiveDirectoryConfig svc) {
        this(svc.getDomain(), svc.getLdapHost(), svc.getSearchBase(), svc.getUsername(), svc.getPassword());
    }

    public ActiveDirectory(final String domain, final String host, final String dn, final String un, final String pw) {
        this.domain = domain;
        this.ldapHost = host;
        this.searchBase = dn;
        this.infoUser = un;
        this.infoPassword = pw;

        trustSelfSignedSSL();
    }

    /**
     * Convert an AD binary ObjectGUID into string form.
     */
    private static String convertGuid(final byte... GUID) {
        final StringBuilder displayStr = new StringBuilder();

        displayStr.append(addLeadingZero((int) GUID[3] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[2] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[1] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[0] & 0xFF));
        displayStr.append("-");
        displayStr.append(addLeadingZero((int) GUID[5] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[4] & 0xFF));
        displayStr.append("-");
        displayStr.append(addLeadingZero((int) GUID[7] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[6] & 0xFF));
        displayStr.append("-");
        displayStr.append(addLeadingZero((int) GUID[8] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[9] & 0xFF));
        displayStr.append("-");
        displayStr.append(addLeadingZero((int) GUID[10] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[11] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[12] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[13] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[14] & 0xFF));
        displayStr.append(addLeadingZero((int) GUID[15] & 0xFF));

        return displayStr.toString();
    }

    private static String addLeadingZero(final int k) {
        return (k < 0xF) ? "0" + Integer.toHexString(k) : Integer.toHexString(k);
    }

    /**
     * The binary data is in the form:
     * byte[0] - revision level
     * byte[1] - count of sub-authorities
     * byte[2-7] - 48 bit authority (big-endian)
     * and then count x 32 bit sub authorities (little-endian)
     * <p>
     * The String value is: S-Revision-Authority-SubAuthority[n]...
     * <p>
     * Based on code from here - http://forums.oracle.com/forums/thread.jspa?threadID=1155740{@literal &}tstart=0
     */
    private static String decodeSID(final byte... sid) {
        final StringBuilder strSid = new StringBuilder("S-");

        // get version
        final int revision = sid[0];
        strSid.append(Integer.toString(revision));

        //next byte is the count of sub-authorities
        final int countSubAuths = sid[1] & 0xFF;

        //get the authority
        long authority = 0;
        //String rid = "";
        for (int i = 2; i <= 7; i++) {
            authority |= ((long) sid[i]) << (8 * (5 - (i - 2)));
        }
        strSid.append("-");
        strSid.append(Long.toHexString(authority));

        //iterate all the sub-auths
        int offset = 8;
        final int size = 4; //4 bytes for each sub auth
        for (int j = 0; j < countSubAuths; j++) {
            long subAuthority = 0;
            for (int k = 0; k < size; k++) {
                subAuthority |= (long) (sid[offset + k] & 0xFF) << (8 * k);
            }

            strSid.append("-");
            strSid.append(subAuthority);

            offset += size;
        }

        return strSid.toString();
    }

    private static byte[] encodeSid(String sid) {
        if (!sid.startsWith("S-") && !sid.startsWith("s-")) {
            throw new IllegalArgumentException("Invalid SID \"" + sid
                    + "\": A valid SID must start with \"S-\".");
        }
        String[] parts = sid.split("-");
        if (parts.length < 3) {
            throw new IllegalArgumentException("Invalid SID \"" + sid
                    + "\": A valid SID must have at least two dashes.");
        }
        if (parts.length > MASK_8_BIT + 3) {
            throw new IllegalArgumentException("Invalid SID \"" + sid
                    + "\": A valid SID must not have more than 257 dashes.");
        }
        int revision;
        try {
            revision = Integer.parseInt(parts[1]);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                    "Invalid revision part in SID \""
                            + sid
                            + "\": The revision must be an integer number between 0 and 255.");
        }
        if (revision < 0 || revision > MASK_8_BIT) {
            throw new IllegalArgumentException(
                    "Invalid revision part in SID \""
                            + sid
                            + "\": The revision must be an integer number between 0 and 255.");
        }
        int numberOfSubAuthorityParts = parts.length - 3;
        long authority;
        try {
            authority = Long.parseLong(parts[2]);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                    "Invalid authority part in SID \""
                            + sid
                            + "\": The authority must be an integer number between 0 and 281474976710655.");
        }
        if (authority < 0 || authority > MASK_48_BIT) {
            throw new IllegalArgumentException(
                    "Invalid authority part in SID \""
                            + sid
                            + "\": The authority must be an integer number between 0 and 281474976710655.");
        }
        int[] subAuthority = new int[numberOfSubAuthorityParts];
        for (int i = 0; i < numberOfSubAuthorityParts; i++) {
            long subAuthorityPart;
            try {
                subAuthorityPart = Long.parseLong(parts[3 + i]);
            } catch (NumberFormatException e) {
                throw new IllegalArgumentException(
                        "Invalid sub-authority part in SID \""
                                + sid
                                + "\": The sub-authority parts must be integer numbers between 0 and 4294967295.");
            }
            if (subAuthorityPart < 0 || subAuthorityPart > MASK_32_BIT) {
                throw new IllegalArgumentException(
                        "Invalid sub-authority part in SID \""
                                + sid
                                + "\": The sub-authority parts must be integer numbers between 0 and 4294967295.");
            }
            subAuthority[i] = (int) subAuthorityPart;
        }
        byte[] bytes = new byte[8 + numberOfSubAuthorityParts * 4];
        // We have to write the authority first, otherwise it would overwrite
        // the revision and length bytes.
        ByteBuffer.wrap(bytes).putLong(authority);
        bytes[0] = (byte) revision;
        bytes[1] = (byte) numberOfSubAuthorityParts;
        ByteBuffer.wrap(bytes, 8, bytes.length - 8)
                .order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().put(subAuthority);
        return bytes;
    }

    @Override
    public String toString() {
        return "ActiveDirectoryStore{" +
                "infoUser='" + infoUser + '\'' +
                ", ldapHost='" + ldapHost + '\'' +
                ", searchBase='" + searchBase + '\'' +
                ", domain='" + domain + '\'' +
                '}';
    }

    /**
     * Return an enumeration of all the groups this given SID belongs to. This is the "correct" way to search for AD
     * group membership as it works via the <code>tokenGroups</code> attribute and will correctly determine
     * membership even through group hierarchies.
     * <p>
     * The SID for the group is the value returned. The object represented by the SID may or may-not be in the
     * context search path.
     * <p>
     * Limitations: According to the Internet the <code>tokenGroups</code> attribute is only available on AD
     * servers which are also GCs.
     * <p>
     * If the user is not found, <code>null</code> is returned.
     */
    public Set<String> findGroups(final String objectSid) throws NamingException {
        // Stupidly - we have to find the user first
        Map userInfo = findUserBySID(objectSid);
        if (userInfo == null) return null;

        logger.info("Found user info: {}", userInfo);
        final String[] returnedAtts = {KEY_TOKENGROUPS};
        final String searchFilter = "(&(objectClass=user)(objectSid=" + objectSid + "))";
        LdapContext ctx = null;

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);
        // SUBTREE is not supported by AD for tokengroups
        searchCtls.setSearchScope(SearchControls.OBJECT_SCOPE);

        HashSet<String> result = new HashSet<>();

        try {
            ctx = newContext();
            final NamingEnumeration userAnswer = ctx.search(userInfo.get(KEY_DN).toString(), searchFilter, searchCtls);

            final StringBuffer groupsSearchFilter = new StringBuffer();
            groupsSearchFilter.append("(|");

            while (userAnswer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) userAnswer.next();
                final Attributes attrs = sr.getAttributes();

                if (attrs != null) {
                    try {
                        for (NamingEnumeration ae = attrs.getAll(); ae.hasMore(); ) {
                            final Attribute attr = (Attribute) ae.next();
                            for (NamingEnumeration e = attr.getAll(); e.hasMore(); ) {
                                byte[] sid = (byte[]) e.next();
                                String sidStr = decodeSID(sid);
                                result.add(sidStr);
                                groupsSearchFilter.append("(objectSid=").append(sidStr).append(")");
                            }
                            groupsSearchFilter.append(")");
                        }
                    } catch (NamingException e) {
                        logger.warn("Problem listing membership: " + e);
                    }
                }
            }

            ctx.close();

            return result;
        } catch (NamingException e) {
            e.printStackTrace();
            return null;
        } finally {
            if (ctx != null) ctx.close();
        }
    }

    /**
     * Returns a <code>LdapContext</code>. The connection will be made with the provided <code>username</code> and
     * <code>password</code>. These contexts are intended to be used in situations where actions can only be properly
     * taken by the own-user (such as changing passwords).
     *
     * @param user
     * @param password
     * @return
     * @throws NamingException
     */
    private LdapContext newContext(final String user, final String password) throws NamingException {
        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, ldapHost);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, user);
        env.put(Context.SECURITY_CREDENTIALS, password);
        env.put(Context.REFERRAL, "follow");
        env.put("java.naming.ldap.attributes.binary", KEY_TOKENGROUPS + " " + KEY_OBJECTSID + " " + KEY_OBJECTGUID);

        return new InitialLdapContext(env, null);
    }

    /**
     * Returns a <code>LdapContext</code> with the pre-configured username & password. These contexts are intended to
     * be used in situations that do not require own-user access, such as reading the contents of the directory.
     *
     * @return
     * @throws NamingException
     */
    private LdapContext newContext() throws NamingException {
        // logger.info("Binding new context. [u: {}] [p: {}]", infoUser, infoPassword.length());
        return newContext(infoUser + "@" + domain, infoPassword);
    }

    /**
     * Return the DN for a user based on SID.
     *
     * @param sid
     * @return
     */
    public String findUserDnBySid(final String sid) {
        Map<String, String> attrs = findUserBySID(sid);
        if (attrs == null) return null;
        return attrs.get("distinguishedName");
    }

    /**
     * Return the DN for a user record based on UPN. This supports long names.
     */
    public String findUserDnByUpn(final String upn) {
        Map<String, String> attrs = findUserByUpn(upn);
        logger.debug("Search returned: {}", attrs);
        if (attrs == null) return null;
        return attrs.get("distinguishedName");
    }

    /**
     * Using the info user, attempt to locate the user with the given SID in the ActiveDirectory instance. If
     * a user record is found, various meta-data about the record is returned in the form of a <code>Map</code>.
     * Otherwise, <code>null</code> is returned.
     * <p>
     * The following attributes are provided: <code>objectGUID, objectSid, distinguishedName, sn, givenName, mail,
     * displayName, pwdLastSet, accountExpires, samAccountName</code>
     *
     * @param sid
     * @return
     */
    public Map findUserBySID(final String sid) {
        final String[] returnedAtts = {"objectGUID", KEY_OBJECTSID, KEY_DN, "sn", "givenName",
                "mail", KEY_DISPLAYNAME, "pwdLastSet", "accountExpires", "samAccountName", KEY_UPN};
        final String searchFilter = "(&(objectClass=user)(objectSid=" + sid + "))";

        logger.info("Searching for SID: {}", sid);

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        LdapContext ctxGC = null;
        try {
            ctxGC = newContext();
            //Search objects in GC using filters

            logger.info("Search base: {} Search filter: {} Search Ctls: {}", searchBase, searchFilter, searchCtls);

            final NamingEnumeration answer = ctxGC.search(searchBase, searchFilter, searchCtls);
            while (answer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) answer.next();
                final Attributes attrs = sr.getAttributes();
                Map amap = null;
                if (attrs != null) {
                    amap = new HashMap();
                    final NamingEnumeration ne = attrs.getAll();
                    while (ne.hasMore()) {
                        final Attribute attr = (Attribute) ne.next();
                        amap.put(attr.getID(), attr.get());
                    }
                    ne.close();
                }

                assert amap != null;
                logger.info("guid type: " + amap.get("objectGUID").getClass().getCanonicalName());
                assert attrs != null;
                amap.put("objectSid", attrs.get("objectSid").get());

                if (amap.containsKey("pwdLastSet")) {
                    amap.put("pwdLastSet", ActiveDirectoryUtil.getDateTimeFrom(String.valueOf(amap.get("pwdLastSet"))));
                }

                logger.info("AD Find returned: {}", amap);
                return amap;
            }
        } catch (NamingException ex) {
            ex.printStackTrace();
        } finally {
            if (ctxGC != null) try {
                ctxGC.close();
            } catch (NamingException e) {
                e.printStackTrace();
            }
        }

        logger.warn("No match for SID in AD: [sid: {}] [ldapHost: {}]", sid, this.ldapHost);

        return null;
    }

    // Greetings. We are from the future. Everything is going to be all right.

    /**
     * Find a user by their UPN. The UPN must be fully specified; for example, foo will not match a foo@domain.com user.
     *
     * @param upn
     * @return
     */
    public Map findUserByUpn(final String upn) {
        logger.debug("Searching for user. [upn: {}]", upn);

        final String[] returnedAtts = {"objectSid", KEY_DN, KEY_ACCOUNT};
        final String searchFilter = "(&(objectClass=user)(userPrincipalName=" + upn + "))";

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);

        //Specify the search scope
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, ldapHost);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, infoUser);
        env.put(Context.SECURITY_CREDENTIALS, infoPassword);
        env.put("java.naming.ldap.attributes.binary", "objectSid");
        final LdapContext ctxGC;

        try {
            ctxGC = new InitialLdapContext(env, null);
            //Search objects in GC using filters
            final NamingEnumeration answer = ctxGC.search(searchBase, searchFilter, searchCtls);
            while (answer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) answer.next();
                final Attributes attrs = sr.getAttributes();
                Map amap = null;
                if (attrs != null) {
                    amap = new HashMap();
                    final NamingEnumeration ne = attrs.getAll();
                    while (ne.hasMore()) {
                        final Attribute attr = (Attribute) ne.next();
                        amap.put(attr.getID(), attr.get());
                    }
                    ne.close();
                }

                logger.debug("Map returns: {}", amap);

                if (amap.size() == 0) return null;

                assert amap != null;
                assert attrs != null;
                amap.put("objectSid", decodeSID((byte[]) attrs.get("objectSid").get()));
                logger.debug("Authentication returned: {}", amap);
                ctxGC.close();
                return amap;
            }
        } catch (NamingException ex) {
            ex.printStackTrace();
        }

        logger.warn("No match for user in AD: {}", upn);

        return null;
    }

    public Set<Map> findUser(final String samAccountName) {
        final String[] returnedAtts = {"objectSid"};
        final String searchFilter = "(&(objectClass=user)(|(sAMAccountName=*" + samAccountName + ")" +
                "(givenName=*" + samAccountName + ")(sn=*" + samAccountName + ")))";

        logger.debug("Search filter: {}", searchFilter);

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        HashSet<Map> result = new HashSet<>();

        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, ldapHost);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, infoUser + "@" + domain);
        env.put(Context.SECURITY_CREDENTIALS, infoPassword);
        env.put("java.naming.ldap.attributes.binary", "objectSid");
        final LdapContext ctxGC;

        try {
            ctxGC = new InitialLdapContext(env, null);
            //Search objects in GC using filters
            final NamingEnumeration answer = ctxGC.search(searchBase, searchFilter, searchCtls);
            while (answer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) answer.next();
                final Attributes attrs = sr.getAttributes();
                Map amap = null;
                if (attrs != null) {
                    amap = new HashMap();
                    final NamingEnumeration ne = attrs.getAll();
                    while (ne.hasMore()) {
                        final Attribute attr = (Attribute) ne.next();
                        amap.put(attr.getID(), attr.get());
                    }
                    ne.close();
                }
                assert amap != null;
                assert attrs != null;
                amap.put("objectSid", decodeSID((byte[]) attrs.get(KEY_OBJECTSID).get()));
                result.add(amap);
            }

        } catch (NamingException ex) {
            ex.printStackTrace();
        }

        return result;
    }

    /**
     * For the given samAccountName, return the objects CN. This method searhes users and groups.
     */
    public String findDnFromSamAccountName(String samAccountName) {
        final String[] returnedAtts = {KEY_DN};
        final String searchFilter = "(&(|(objectClass=user)(objectClass=group))(|(sAMAccountName=*" + samAccountName + ")" +
                "(givenName=*" + samAccountName + ")(sn=*" + samAccountName + ")))";

        logger.debug("Search filter: {}", searchFilter);

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, ldapHost);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, infoUser);
        env.put(Context.SECURITY_CREDENTIALS, infoPassword);
        LdapContext ctxGC = null;

        try {
            ctxGC = new InitialLdapContext(env, null);
            //Search objects in GC using filters
            final NamingEnumeration answer = ctxGC.search(searchBase, searchFilter, searchCtls);
            while (answer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) answer.next();
                final Attributes attrs = sr.getAttributes();
                Attribute dnAttr = attrs.get(KEY_DN);
                if (dnAttr != null) {
                    return dnAttr.get().toString();
                }
            }
        } catch (NamingException ex) {
            ex.printStackTrace();
        } finally {
            closeCtx(ctxGC);
        }
        return null;
    }

    private void closeCtx(LdapContext ctx) {
        try {
            if (ctx != null) ctx.close();
        } catch (NamingException e) {
            e.printStackTrace();
        }
    }

    /**
     * Find all the groups within the configured search base. Returns the <code>objectSid</code> and
     * <code>displayName</code> properties.
     */
    public Set<Map> findAllGroups() {
        final String[] returnedAtts = {"objectSid", "displayName", "cn", "name"};
        final String searchFilter = "(objectClass=group)";

        logger.debug("Search query: [filter: {}] [base: {}] [attrs: {}]", searchFilter, searchBase, returnedAtts);

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        HashSet<Map> result = new HashSet<>();

        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, ldapHost);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, infoUser);
        env.put(Context.SECURITY_CREDENTIALS, infoPassword);
        env.put("java.naming.ldap.attributes.binary", "objectSid");
        final LdapContext ctxGC;

        try {
            ctxGC = new InitialLdapContext(env, null);
            //Search objects in GC using filters
            final NamingEnumeration answer = ctxGC.search(searchBase, searchFilter, searchCtls);
            while (answer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) answer.next();
                final Attributes attrs = sr.getAttributes();
                Map amap = null;
                if (attrs != null) {
                    amap = new HashMap();
                    final NamingEnumeration ne = attrs.getAll();
                    while (ne.hasMore()) {
                        final Attribute attr = (Attribute) ne.next();
                        amap.put(attr.getID(), attr.get());
                    }
                    ne.close();
                }
                assert amap != null;
                assert attrs != null;
                amap.put("objectSid", decodeSID((byte[]) attrs.get("objectSid").get()));
                result.add(amap);
            }
        } catch (NamingException ex) {
            ex.printStackTrace();
        }

        return result;
    }

    /**
     * Attempt to authenticate the given user against the ActiveDirectory store. If the user is successfully
     * authenticated, a <code>Map</code> is returned containing account information. If the user does not
     * exist or the provided password is not correct, <code>null</code> is returned.
     * <p>
     * The map will contain at least <code>KEY_SID</code> which contains the objectSid for the associated
     * LDAP record.
     * <p>
     * The bind is performed with the provider username & password, so the global user & password does not have
     * to be set for this to work.
     *
     * @param user The account name.
     * @param pass The account password.
     * @return
     */
    public Map authenticate(String user, final String pass) {
        // It's possible that user is in AD format...

        String un[] = user.split("\\\\");
        if (un.length == 2) {
            user = un[1];
        }

        logger.debug("Looking for username: {}", user);

        final String[] returnedAtts = {"objectSid"};
        final String searchFilter = "(&(objectClass=user)(sAMAccountName=" + user + "))";

        //Create the search controls
        final SearchControls searchCtls = new SearchControls();
        searchCtls.setReturningAttributes(returnedAtts);

        //Specify the search scope
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        final Hashtable env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, ldapHost);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, user + "@" + domain);
        env.put(Context.SECURITY_CREDENTIALS, pass);
        env.put("java.naming.ldap.attributes.binary", "objectSid");
        final LdapContext ctxGC;

        try {
            ctxGC = new InitialLdapContext(env, null);
            //Search objects in GC using filters
            final NamingEnumeration answer = ctxGC.search(searchBase, searchFilter, searchCtls);
            while (answer.hasMoreElements()) {
                final SearchResult sr = (SearchResult) answer.next();
                final Attributes attrs = sr.getAttributes();
                Map amap = null;
                if (attrs != null) {
                    amap = new HashMap();
                    final NamingEnumeration ne = attrs.getAll();
                    while (ne.hasMore()) {
                        final Attribute attr = (Attribute) ne.next();
                        amap.put(attr.getID(), attr.get());
                    }
                    ne.close();
                }
                assert amap != null;
                assert attrs != null;
                amap.put("objectSid", decodeSID((byte[]) attrs.get("objectSid").get()));
                logger.debug("Authentication returned: {}", amap);

                return amap;
            }
        } catch (NamingException ex) {
            ex.printStackTrace();
        }

        logger.warn("No match for user in AD: {}", user);

        return null;
    }

    /**
     * Change the password for the given user.
     *
     * @param password
     * @throws Exception
     */
    public void updatePassword(final String durableId, final String password)
            throws Exception {

        logger.info("Updating password. [u: {}] [p: {}]", durableId, password);

        // Step #1 = Convert the SID to an account name
        final Map userInfo = findUserBySID(durableId);
        if (userInfo == null)
            throw new Exception("No such user");

        final LdapContext ctxGC = newContext();

        final byte[] quotedPasswordBytes = ('"' + password + '"').getBytes("UTF-16LE");

        final ModificationItem[] mods = new ModificationItem[1];
        mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE,
                new BasicAttribute("UnicodePwd", quotedPasswordBytes));
        ctxGC.modifyAttributes((String) userInfo.get(KEY_DN), mods);
    }

    /**
     * Create a new AD user by specifying the UPN instead of the sAMAccountName.
     * <p>
     * If the user-part of the UPN is 20 characters or less, the sAMAccountName will be set to the user-part.
     * Otherwise, it will be left blank, which will cause AD to auto-generate a sAMAccountName string.
     *
     * @throws NamingException
     * @throws UnsupportedEncodingException
     */
    public String createUserUpn(String upn, String password, String subOrgUnit) throws NamingException,
            UnsupportedEncodingException {
        logger.debug("Creating user. [username: {}] [OU: {}]", upn, subOrgUnit);

        // AD will let us create a UPN w/o a domain name which causes problems
        if (!StringUtils.contains(upn, "@")) {
            throw new IllegalArgumentException("UPN must be in email format.");
        }

        String sama = null;
        String upnParts[] = upn.split("@", 2);
        if (upnParts[0].length() <= 20) {
            // sama = upnParts[0];
        }

        logger.debug("Determine SAM name: {}", sama);

        LdapContext context = newContext();
        try {
            // Create a container set of attributes
            Attributes container = new BasicAttributes();

            // Create the objectclass to add
            Attribute objClasses = new BasicAttribute("objectClass");
            objClasses.add("top");
            objClasses.add("person");
            objClasses.add("organizationalPerson");
            objClasses.add("user");

            Attribute cn = new BasicAttribute("cn", upn);
            container.put(objClasses);
            Attribute upnAttr = new BasicAttribute(KEY_UPN, upn);
            container.put(upnAttr);

            if (sama != null) {
                Attribute samaAttr = new BasicAttribute(KEY_ACCOUNT, sama);
                container.put(samaAttr);
            }

            container.put(cn);

            // TODO: Protect against stringbashing
            String distinguishedName;
            if (subOrgUnit != null) {
                distinguishedName = "cn=" + upn + "," + subOrgUnit + "," + searchBase;
            } else {
                distinguishedName = "cn=" + upn + "," + searchBase;
            }

            logger.debug("Build dn: {}", distinguishedName);

            context.createSubcontext(distinguishedName, container);

            logger.debug("Account created.");

            ModificationItem[] mods = new ModificationItem[2];
            String newQuotedPassword = "\"" + password + "\"";
            byte[] newUnicodePassword = newQuotedPassword.getBytes("UTF-16LE");

            mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, new BasicAttribute("unicodePwd", newUnicodePassword));
            mods[1] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, new BasicAttribute("userAccountControl", Integer
                    .toString(UF_NORMAL_ACCOUNT + UF_DONT_EXPIRE_PASSWD)));
            context.modifyAttributes(distinguishedName, mods);

            logger.debug("Account password updated.");

            return distinguishedName;
        } finally {
            if (context != null) context.close();
        }
    }

    /**
     * Create a new group in AD.
     *
     * @param groupName The name for the group.
     * @param baseDn    The OU base to append to the group.
     * @throws NamingException
     */
    public void createDomainLocalSecurityGroup(String groupName, String baseDn) throws NamingException {
        logger.debug("Adding group...");

        // Create a container set of attributes
        Attributes container = new BasicAttributes();

        // Create the objectclass to add
        Attribute objClasses = new BasicAttribute("objectClass", "group");

        // Assign the username, first name, and last name
        Attribute cn = new BasicAttribute("cn", groupName);
        Attribute sAMAccountName = new BasicAttribute("name", groupName);
        Attribute principalName = new BasicAttribute("sAMAccountName", groupName);

        container.put("groupType", Integer.toString(ADS_GROUP_TYPE_DOMAIN_LOCAL_GROUP + ADS_GROUP_TYPE_SECURITY_ENABLED));

        // Add these to the container
        container.put(objClasses);
        container.put(sAMAccountName);
        container.put(principalName);
        // Domain local
        container.put(new BasicAttribute("groupType", "4"));
        container.put(cn);

        LdapContext ctx = null;
        try {
            ctx = newContext();
            String groupOu = "cn=" + groupName + "," + baseDn;
            logger.debug("Group OU: {}", groupOu);
            ctx.createSubcontext(groupOu, container);
        } finally {
            if (ctx != null) ctx.close();
        }
    }

    /**
     * Add the source DN as a member of the destination DN. Note that these arguments are different than
     * removeMember()! If the object is already a member of the group, this method silently returns.
     */
    public void addMemberDn(String dstDn, String srcDn) throws NamingException {

        //  dstDn = findDnFromSamAccountName("test");
        // srcDn = findDnFromSamAccountName("Administrator");
        // Create the initial directory context
        LdapContext ctx = newContext();
        try {
            //Create a LDAP add attribute for the member attribute
            ModificationItem mods[] = new ModificationItem[1];
            mods[0] = new ModificationItem(DirContext.ADD_ATTRIBUTE, new BasicAttribute("member", srcDn));
            //update the group
            logger.info("Adding member to group. [dst: {}] [src: {}] {}", dstDn, srcDn, mods[0]);
            ctx.modifyAttributes(dstDn, mods);
        } catch (NameAlreadyBoundException e) {
            logger.debug("addMemberDn(): Already a member. {}", e);
        } finally {
            ctx.close();
        }
    }

    public void addMember(String dst, String src) throws NamingException {
        String dstDn = findDnFromSamAccountName(dst);
        String srcDn = findDnFromSamAccountName(src);

        if (dstDn == null || srcDn == null) {
            logger.warn("addMember(): Failed to locate arguments. [dstDn: {}] [srcDn: {}]", dstDn, srcDn);
            return;
        }

        addMemberDn(dstDn, srcDn);
    }

    /**
     * Remove the source samAccountName as a member of the destination samAccountName. Note that these arguments
     * are different than addMemberDn()! If either objects is not found, the method returns silently.
     */
    public void removeMemberDn(String dstDn, String srcDn) throws NamingException {
        // Create the initial directory context
        LdapContext ctx = newContext();
        try {
            //Create a LDAP add attribute for the member attribute
            ModificationItem mods[] = new ModificationItem[1];
            mods[0] = new ModificationItem(DirContext.REMOVE_ATTRIBUTE, new BasicAttribute("member", srcDn));

            //update the group
            ctx.modifyAttributes(dstDn, mods);
        } finally {
            ctx.close();
        }
    }

    public void removeMember(String dst, String src) throws NamingException {
        String dstDn = findDnFromSamAccountName(dst);
        String srcDn = findDnFromSamAccountName(src);

        if (dstDn == null || srcDn == null) {
            logger.warn("removeMember(): Failed to locate arguments. [dstDn: {}] [srcDn: {}]", dstDn, srcDn);
            return;
        }

        removeMemberDn(dstDn, srcDn);
    }

    private static void trustSelfSignedSSL() {
        try {
            SSLContext ctx = SSLContext.getInstance("TLS");
            X509TrustManager tm = new X509TrustManager() {

                public void checkClientTrusted(X509Certificate[] xcs, String string) throws CertificateException {
                }

                public void checkServerTrusted(X509Certificate[] xcs, String string) throws CertificateException {
                }

                public X509Certificate[] getAcceptedIssuers() {
                    return null;
                }
            };
            ctx.init(null, new TrustManager[]{tm}, null);
            SSLContext.setDefault(ctx);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    /**
     * Find all the users within the provided search base.
     *
     * @param searchBase
     * @param sink
     * @throws Exception
     */
    public void findUsers(final String searchBase, final ADUserSink sink) throws Exception {
        final int pageSize = 100;
        byte[] cookie = null;
        LdapContext ctx = newContext();
        try {
            final SearchControls ctls = new SearchControls();
            final String[] attrIDs = {KEY_CN, KEY_ACCOUNT, "userAccountControl", "whenCreated", KEY_OBJECTSID, KEY_DISPLAYNAME};
            ctls.setReturningAttributes(attrIDs);
            ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
            ctx.setRequestControls(new Control[]{
                    new PagedResultsControl(pageSize, Control.CRITICAL)});

            final SimpleDateFormat dtf = new SimpleDateFormat("yyyyMMddHHmmss");
            dtf.setTimeZone(TimeZone.getTimeZone("UTC"));


            logger.info("LDAP environment: {}", ctx.getEnvironment());

            do {
                final NamingEnumeration answer = ctx.search(searchBase, "(objectclass=user)", ctls);

                while (answer.hasMore()) {
                    final SearchResult rslt = (SearchResult) answer.next();
                    final Attributes attrs = rslt.getAttributes();
                    final Integer uac = Integer.parseInt(attrs.get("userAccountControl").get().toString());

                    logger.info("Record: {}", attrs);

                    sink.record(rslt.getNameInNamespace(),
                            attrs.get(KEY_ACCOUNT).get().toString(),
                            attrs.get(KEY_CN).get().toString(),
                            null,
                            decodeSID((byte[]) attrs.get(KEY_OBJECTSID).get()),
                            (uac & 0x0002) == 0,
                            dtf.parse(attrs.get("whenCreated").get().toString()));
                }

                // Examine the paged results control response
                final Control[] controls = ctx.getResponseControls();
                if (controls != null) {
                    for (Control control : controls) {
                        if (control instanceof PagedResultsResponseControl) {
                            final PagedResultsResponseControl prrc = (PagedResultsResponseControl) control;
                            cookie = prrc.getCookie();
                        }
                    }
                }

                // Re-activate paged results
                ctx.setRequestControls(new Control[]{
                        new PagedResultsControl(pageSize, cookie, Control.CRITICAL)});

            } while (cookie != null);
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        } finally {
            ctx.close();
        }
    }
}
