All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.graylog2.security.ldap.LdapConnector Maven / Gradle / Ivy

There is a newer version: 5.2.7
Show newest version
/**
 * This file is part of Graylog.
 *
 * Graylog is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Graylog is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Graylog.  If not, see .
 */
package org.graylog2.security.ldap;

import com.google.common.base.Joiner;
import com.google.common.collect.Sets;
import com.google.common.util.concurrent.SimpleTimeLimiter;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.google.common.util.concurrent.UncheckedTimeoutException;
import org.apache.directory.api.ldap.model.cursor.CursorException;
import org.apache.directory.api.ldap.model.cursor.EntryCursor;
import org.apache.directory.api.ldap.model.entry.Attribute;
import org.apache.directory.api.ldap.model.entry.Entry;
import org.apache.directory.api.ldap.model.entry.Value;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.exception.LdapInvalidDnException;
import org.apache.directory.api.ldap.model.message.BindRequestImpl;
import org.apache.directory.api.ldap.model.message.BindResponse;
import org.apache.directory.api.ldap.model.message.ResultCodeEnum;
import org.apache.directory.api.ldap.model.message.SearchScope;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.ldap.client.api.LdapConnectionConfig;
import org.apache.directory.ldap.client.api.LdapNetworkConnection;
import org.graylog2.plugin.DocsHelper;
import org.graylog2.shared.security.ldap.LdapEntry;
import org.graylog2.shared.utilities.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Named;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.Collections;
import java.util.Iterator;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.isNullOrEmpty;

public class LdapConnector {
    private static final Logger LOG = LoggerFactory.getLogger(LdapConnector.class);

    private static final String ATTRIBUTE_UNIQUE_MEMBER = "uniqueMember";
    private static final String ATTRIBUTE_MEMBER = "member";
    private static final String ATTRIBUTE_MEMBER_UID = "memberUid";

    private final int connectionTimeout;

    @Inject
    public LdapConnector(@Named("ldap_connection_timeout") int connectionTimeout) {
        this.connectionTimeout = connectionTimeout;
    }

    public LdapNetworkConnection connect(LdapConnectionConfig config) throws LdapException {
        final LdapNetworkConnection connection = new LdapNetworkConnection(config);
        connection.setTimeOut(connectionTimeout);

        if (LOG.isTraceEnabled()) {
            LOG.trace("Connecting to LDAP server {}:{}, binding with user {}",
                      config.getLdapHost(), config.getLdapPort(), config.getName());
        }

        // this will perform an anonymous bind if there were no system credentials
        final ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("ldap-connector-%d").build();
        final SimpleTimeLimiter timeLimiter = new SimpleTimeLimiter(Executors.newSingleThreadExecutor(threadFactory));
        @SuppressWarnings("unchecked")
        final Callable timeLimitedConnection = timeLimiter.newProxy(
                new Callable() {
                    @Override
                    public Boolean call() throws Exception {
                        return connection.connect();
                    }
                }, Callable.class,
                connectionTimeout, TimeUnit.MILLISECONDS);
        try {
            final Boolean connected = timeLimitedConnection.call();
            if (!connected) {
                return null;
            }
        } catch (UncheckedTimeoutException e) {
            LOG.error("Timed out connecting to LDAP server", e);
            throw new LdapException("Could not connect to LDAP server", e.getCause());
        } catch (LdapException e) {
            throw e;
        } catch (Exception e) {
            // unhandled different exception, should really not happen here.
            throw new LdapException("Unexpected error connecting to LDAP", e);
        }
        connection.bind();

        return connection;
    }

    @Nullable
    public LdapEntry search(LdapNetworkConnection connection,
                            String searchBase,
                            String searchPattern,
                            String displayNameAttribute,
                            String principal,
                            boolean activeDirectory,
                            String groupSearchBase,
                            String groupIdAttribute,
                            String groupSearchPattern) throws LdapException, CursorException {
        final LdapEntry ldapEntry = new LdapEntry();
        final Set groupDns = Sets.newHashSet();

        final String filter = new MessageFormat(searchPattern, Locale.ENGLISH).format(new Object[]{sanitizePrincipal(principal)});
        if (LOG.isTraceEnabled()) {
            LOG.trace("Search {} for {}, starting at {}",
                      activeDirectory ? "ActiveDirectory" : "LDAP", filter, searchBase);
        }

        try (final EntryCursor entryCursor = connection.search(searchBase,
                filter,
                SearchScope.SUBTREE,
                groupIdAttribute, displayNameAttribute, "dn", "uid", "userPrincipalName", "mail", "rfc822Mailbox", "memberOf", "isMemberOf")
        ) {
            final Iterator it = entryCursor.iterator();
            if (it.hasNext()) {
                final Entry e = it.next();
                // always set the proper DN for the entry, we need it for group matching
                ldapEntry.setDn(e.getDn().getName());

                // for generic LDAP use the dn of the entry for the subsequent bind, active directory needs the userPrincipalName attribute (set below)
                if (!activeDirectory) {
                    ldapEntry.setBindPrincipal(e.getDn().getName());
                }

                for (Attribute attribute : e.getAttributes()) {
                    if (activeDirectory && "userPrincipalName".equalsIgnoreCase(attribute.getId())) {
                        ldapEntry.setBindPrincipal(attribute.getString());
                    }
                    if (attribute.isHumanReadable()) {
                        ldapEntry.put(attribute.getId(), Joiner.on(", ").join(attribute.iterator()));
                    }
                    // ActiveDirectory (memberOf) and Sun Directory Server (isMemberOf)
                    if ("memberOf".equalsIgnoreCase(attribute.getId()) || "isMemberOf".equalsIgnoreCase(attribute.getId())) {
                        for (Value group : attribute) {
                            groupDns.add(group.getString());
                        }

                    }
                }
            } else {
                LOG.trace("No LDAP entry found for filter {}", filter);
                return null;
            }
            if (!groupDns.isEmpty() && !isNullOrEmpty(groupSearchBase) && !isNullOrEmpty(groupIdAttribute)) {
                // user had a memberOf attribute which contained group references. resolve each group and collect group names
                // according to groupIdAttribute if present
                try {
                    for (String groupDn : groupDns) {
                        LOG.trace("Looking up group {}", groupDn);
                        try {
                            Entry group = connection.lookup(groupDn, groupIdAttribute);
                            // The groupDn lookup can return null if the group belongs to a different domain and the
                            // connection user does not have the permissions to lookup details.
                            // See: https://github.com/Graylog2/graylog2-server/issues/1453
                            if (group != null) {
                                final Attribute groupId = group.get(groupIdAttribute);
                                LOG.trace("Resolved {} to group {}", groupDn, groupId);
                                if (groupId != null) {
                                    final String string = groupId.getString();
                                    ldapEntry.addGroups(Collections.singleton(string));
                                }
                            } else {
                                LOG.debug("Unable to lookup group: {}", groupDn);
                            }
                        } catch (LdapException e) {
                            LOG.warn("Error while looking up group " + groupDn, e);
                        }
                    }
                } catch (Exception e) {
                    LOG.error("Unexpected error during LDAP group resolution", e);
                }
            }
            if (ldapEntry.getGroups().isEmpty() && !isNullOrEmpty(groupSearchBase) && !isNullOrEmpty(groupIdAttribute) && !isNullOrEmpty(groupSearchPattern)) {
                ldapEntry.addGroups(findGroups(connection,
                                               groupSearchBase,
                                               groupSearchPattern,
                                               groupIdAttribute,
                                               ldapEntry
                ));
                LOG.trace("LDAP search found entry for DN {} with search filter {}: {}",
                          ldapEntry.getDn(),
                          filter,
                          ldapEntry);
            } else {
                if (groupDns.isEmpty()) {
                    LOG.info("LDAP group search base, id attribute or object class missing, not iterating over LDAP groups.");
                }
            }
            return ldapEntry;
        } catch (IOException e) {
            LOG.debug("Error while closing cursor", e);
            return null;
        }
    }

    public Set findGroups(LdapNetworkConnection connection,
                                  String groupSearchBase,
                                  String groupSearchPattern,
                                  String groupIdAttribute,
                                  @Nullable LdapEntry ldapEntry) {
        final Set groups = Sets.newHashSet();

        try (final EntryCursor groupSearch = connection.search(
                groupSearchBase,
                groupSearchPattern,
                SearchScope.SUBTREE,
                "objectClass", ATTRIBUTE_UNIQUE_MEMBER, ATTRIBUTE_MEMBER, ATTRIBUTE_MEMBER_UID, groupIdAttribute)) {
            LOG.trace("LDAP search for groups: {} starting at {}", groupSearchPattern, groupSearchBase);
            for (Entry e : groupSearch) {
                if (LOG.isTraceEnabled()) {
                    LOG.trace("Group Entry: {}", e.toString("  "));
                }
                if (! e.containsAttribute(groupIdAttribute)) {
                    LOG.warn("Unknown group id attribute {}, skipping group entry {}", groupIdAttribute, e);
                    continue;
                }
                final String groupId = e.get(groupIdAttribute).getString();
                if (ldapEntry == null) {
                    // no membership lookup possible (we have no user), simply collect the found group names
                    groups.add(groupId);
                } else {
                    // test if the given dn parameter is actually member of any of the found groups
                    String memberAttribute;
                    if (e.hasObjectClass("groupOfUniqueNames")) {
                        memberAttribute = ATTRIBUTE_UNIQUE_MEMBER;
                    } else if (e.hasObjectClass("groupOfNames") || e.hasObjectClass("group")) {
                        memberAttribute = ATTRIBUTE_MEMBER;
                    } else if (e.hasObjectClass("posixGroup")) {
                        memberAttribute = ATTRIBUTE_MEMBER_UID;
                    } else {
                        // Trying auto detection of the member attribute. This should be configurable!
                        if (e.containsAttribute(ATTRIBUTE_UNIQUE_MEMBER)) {
                            memberAttribute = ATTRIBUTE_UNIQUE_MEMBER;
                        } else if (e.containsAttribute(ATTRIBUTE_MEMBER_UID)) {
                            memberAttribute = ATTRIBUTE_MEMBER_UID;
                        } else {
                            memberAttribute = ATTRIBUTE_MEMBER;
                        }
                        LOG.warn(
                                "Unable to auto-detect the LDAP group object class, assuming '{}' is the correct attribute.",
                                memberAttribute);
                    }
                    final Attribute members = e.get(memberAttribute);
                    if (members != null) {
                        final String dn = normalizedDn(ldapEntry.getDn());
                        final String uid = ldapEntry.get("uid");

                        for (Value member : members) {
                            LOG.trace("DN {} == {} member?", dn, member.getString());
                            if (dn != null && dn.equalsIgnoreCase(normalizedDn(member.getString()))) {
                                groups.add(groupId);
                            } else {
                                // The posixGroup object class is using the memberUid attribute for group members.
                                // Since the memberUid attribute takes uid values instead of dn values, we have to
                                // check against the uid attribute of the user.
                                if (!isNullOrEmpty(uid) && uid.equalsIgnoreCase(member.getString())) {
                                    LOG.trace("UID {} == {} member?", uid, member.getString());
                                    groups.add(groupId);
                                }
                            }
                        }
                    }
                }
            }
        } catch (Exception e) {
            LOG.warn(
                    "Unable to iterate over user's groups, unable to perform group mapping. Graylog does not support " +
                            "LDAP referrals at the moment. Please see " +
                            DocsHelper.PAGE_LDAP_TROUBLESHOOTING.toString() + " for more information.",
                    ExceptionUtils.getRootCause(e));
        }

        return groups;
    }

    /**
     * When the given string is a DN, the method ensures that the DN gets normalized so it can be used in string
     * comparison.
     *
     * If the string is not a DN, the method just returns it.
     *
     * Examples:
     *
     * String is a DN:
     *   input  = "cn=John Doe, ou=groups, ou=system"
     *   output = "cn=John Doe,ou=groups,ou=system"
     *
     * String is not a DN:
     *   input  = "john"
     *   output = "john"
     *
     * This behavior is needed because for some values we don't know if the value is a DN or not. (i.e. group member values)
     *
     * See: https://github.com/Graylog2/graylog2-server/issues/1790
     *
     * @param dn denormalized DN string
     * @return normalized DN string
     */
    @Nullable
    private String normalizedDn(String dn) {
        if (isNullOrEmpty(dn)) {
            return dn;
        } else {
            try {
                return new Dn(dn).getNormName();
            } catch (LdapInvalidDnException e) {
                LOG.debug("Invalid DN", e);
                return dn;
            }
        }
    }

    public Set listGroups(LdapNetworkConnection connection,
                                  String groupSearchBase,
                                  String groupSearchPattern,
                                  String groupIdAttribute) {
        return findGroups(connection, groupSearchBase, groupSearchPattern, groupIdAttribute, null);
    }

    /**
     * Escapes any special chars (RFC 4515) from a string representing a
     * a search filter assertion value.
     *
     * @param input The input string.
     * @return A assertion value string ready for insertion into a
     * search filter string.
     */
    private String sanitizePrincipal(final String input) {
        String s = "";

        for (int i = 0; i < input.length(); i++) {
            char c = input.charAt(i);

            if (c == '*') {
                // escape asterisk
                s += "\\2a";
            } else if (c == '(') {
                // escape left parenthesis
                s += "\\28";
            } else if (c == ')') {
                // escape right parenthesis
                s += "\\29";
            } else if (c == '\\') {
                // escape backslash
                s += "\\5c";
            } else if (c == '\u0000') {
                // escape NULL char
                s += "\\00";
            } else if (c <= 0x7f) {
                // regular 1-byte UTF-8 char
                s += String.valueOf(c);
            } else if (c >= 0x080) {
                // higher-order 2, 3 and 4-byte UTF-8 chars
                byte[] utf8bytes = String.valueOf(c).getBytes(StandardCharsets.UTF_8);

                for (byte b : utf8bytes) {
                    s += String.format(Locale.ENGLISH, "\\%02x", b);
                }
            }
        }

        return s;
    }

    public boolean authenticate(LdapNetworkConnection connection, String principal, String credentials) throws LdapException {
        checkArgument(!isNullOrEmpty(principal), "Binding with empty principal is forbidden.");
        checkArgument(!isNullOrEmpty(credentials), "Binding with empty credentials is forbidden.");

        final BindRequestImpl bindRequest = new BindRequestImpl();
        bindRequest.setName(principal);
        bindRequest.setCredentials(credentials);
        LOG.trace("Re-binding with DN {} using password", principal);
        final BindResponse bind = connection.bind(bindRequest);
        if (!bind.getLdapResult().getResultCode().equals(ResultCodeEnum.SUCCESS)) {
            LOG.trace("Re-binding DN {} failed", principal);
            throw new RuntimeException(bind.toString());
        }
        LOG.trace("Binding DN {} did not throw, connection authenticated: {}", principal, connection.isAuthenticated());
        return connection.isAuthenticated();
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy