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

org.graylog.security.authservice.ldap.UnboundLDAPConnector Maven / Gradle / Ivy

There is a newer version: 6.1.4
Show newest version
/*
 * Copyright (C) 2020 Graylog, Inc.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the Server Side Public License, version 1,
 * as published by MongoDB, Inc.
 *
 * This program 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
 * Server Side Public License for more details.
 *
 * You should have received a copy of the Server Side Public License
 * along with this program. If not, see
 * .
 */
package org.graylog.security.authservice.ldap;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.primitives.Ints;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.BindRequest;
import com.unboundid.ldap.sdk.BindResult;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.ExtendedResult;
import com.unboundid.ldap.sdk.FailoverServerSet;
import com.unboundid.ldap.sdk.Filter;
import com.unboundid.ldap.sdk.LDAPBindException;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPConnectionOptions;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.ldap.sdk.SearchRequest;
import com.unboundid.ldap.sdk.SearchResult;
import com.unboundid.ldap.sdk.SearchScope;
import com.unboundid.ldap.sdk.SimpleBindRequest;
import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest;
import com.unboundid.util.Base64;
import com.unboundid.util.LDAPTestUtils;
import com.unboundid.util.ssl.SSLUtil;
import org.graylog2.configuration.TLSProtocolsConfiguration;
import org.graylog2.security.TrustAllX509TrustManager;
import org.graylog2.security.TrustManagerProvider;
import org.graylog2.security.encryption.EncryptedValue;
import org.graylog2.security.encryption.EncryptedValueService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.inject.Singleton;

import javax.net.SocketFactory;
import java.security.GeneralSecurityException;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.unboundid.util.StaticUtils.isValidUTF8;
import static com.unboundid.util.StaticUtils.toUTF8String;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.isBlank;

// TODO: Possible improvements:
//   - Use a connection pool to improve performance and reduce load (see: https://docs.ldap.com/ldap-sdk/docs/getting-started/connection-pools.html)
//   - Support connecting to multiple servers for failover and load balancing (see: https://docs.ldap.com/ldap-sdk/docs/getting-started/failover-load-balancing.html)
@Singleton
public class UnboundLDAPConnector {
    private static final Logger LOG = LoggerFactory.getLogger(UnboundLDAPConnector.class);

    private static final String OBJECT_CLASS_ATTRIBUTE = "objectClass";

    private final int connectionTimeout;
    private final TLSProtocolsConfiguration tlsConfiguration;
    private final TrustManagerProvider trustManagerProvider;
    private final EncryptedValueService encryptedValueService;
    private final int requestTimeoutSeconds;

    @Inject
    public UnboundLDAPConnector(@Named("ldap_connection_timeout") int connectionTimeout,
                                TLSProtocolsConfiguration tlsConfiguration,
                                TrustManagerProvider trustManagerProvider,
                                EncryptedValueService encryptedValueService) {
        this.connectionTimeout = connectionTimeout; // TODO: Make configurable per backend
        this.tlsConfiguration = tlsConfiguration;
        this.trustManagerProvider = trustManagerProvider;
        this.encryptedValueService = encryptedValueService;
        this.requestTimeoutSeconds = 60; // TODO: Make configurable per backend
    }

    public LDAPConnection connect(LDAPConnectorConfig ldapConfig) throws GeneralSecurityException, LDAPException {
        if (ldapConfig.serverList().isEmpty()) {
            LOG.warn("Cannot connect with empty server list");
            return null;
        }

        final String[] addresses = ldapConfig.serverList().stream().map(LDAPConnectorConfig.LDAPServer::hostname).toArray(String[]::new);
        final int[] ports = ldapConfig.serverList().stream().mapToInt(LDAPConnectorConfig.LDAPServer::port).toArray();

        final LDAPConnectionOptions connectionOptions = new LDAPConnectionOptions();
        connectionOptions.setUseReuseAddress(true);
        connectionOptions.setConnectTimeoutMillis(connectionTimeout);

        StartTLSExtendedRequest startTLSRequest = null;
        SocketFactory socketFactory = null;
        if (ldapConfig.transportSecurity() != LDAPTransportSecurity.NONE) {
            SSLUtil.setEnabledSSLProtocols(tlsConfiguration.getEnabledTlsProtocols());

            final SSLUtil sslUtil;
            if (ldapConfig.verifyCertificates()) {
                sslUtil = new SSLUtil(trustManagerProvider.create(Arrays.asList(addresses)));
            } else {
                sslUtil = new SSLUtil(new TrustAllX509TrustManager());
            }

            if (ldapConfig.transportSecurity() == LDAPTransportSecurity.START_TLS) {
                // Use the StartTLS extended operation to secure the connection.
                startTLSRequest = new StartTLSExtendedRequest(sslUtil.createSSLContext());
            } else if (ldapConfig.transportSecurity() == LDAPTransportSecurity.TLS) {
                socketFactory = sslUtil.createSSLSocketFactory();
            }
        }

        final FailoverServerSet serverSet = new FailoverServerSet(addresses, ports, socketFactory, connectionOptions, null, null);

        final LDAPConnection connection = serverSet.getConnection();

        if (startTLSRequest != null) {
            final ExtendedResult startTLSResult = connection.processExtendedOperation(startTLSRequest);
            LDAPTestUtils.assertResultCodeEquals(startTLSResult, ResultCode.SUCCESS);
        }

        if (ldapConfig.systemUsername().isPresent()) {
            if (ldapConfig.systemPassword().isSet()) {
                final String systemPassword = encryptedValueService.decrypt(ldapConfig.systemPassword());
                final BindRequest bindRequest = new SimpleBindRequest(ldapConfig.systemUsername().get(), systemPassword);
                connection.bind(bindRequest);
            } else {
                LOG.warn("System username has been set to <{}> but no system password has been set. Skipping bind request.",
                        ldapConfig.systemUsername().get());
            }
        }
        return connection;
    }

    public ImmutableList search(LDAPConnection connection,
                                           String searchBase,
                                           Filter filter,
                                           String uniqueIdAttribute,
                                           Set attributes) throws LDAPException {
        final ImmutableSet allAttributes = ImmutableSet.builder()
                .add(OBJECT_CLASS_ATTRIBUTE)
                .addAll(attributes)
                .build();
        // TODO: Use LDAPEntrySource for a more memory efficient search
        final SearchRequest searchRequest = new SearchRequest(searchBase, SearchScope.SUB, filter, allAttributes.toArray(new String[0]));
        searchRequest.setTimeLimitSeconds(requestTimeoutSeconds);

        if (LOG.isTraceEnabled()) {
            LOG.trace("Search LDAP for <{}> using search base <{}>", filter.toNormalizedString(), searchBase);
        }

        final SearchResult searchResult = connection.search(searchRequest);
        if (searchResult.getSearchEntries().isEmpty()) {
            LOG.trace("No LDAP entry found for filter <{}>", filter.toNormalizedString());
            return ImmutableList.of();
        }

        return searchResult.getSearchEntries().stream()
                .map(entry -> createLDAPEntry(entry, uniqueIdAttribute))
                .collect(ImmutableList.toImmutableList());
    }

    public Optional searchUserByPrincipal(LDAPConnection connection,
                                                    UnboundLDAPConfig config,
                                                    String principal) throws LDAPException {
        final String filterString = new MessageFormat(config.userSearchPattern(), Locale.ENGLISH)
                .format(new Object[]{Filter.encodeValue(principal)});

        return searchUser(connection, config, Filter.create(filterString));
    }

    public Optional searchUserByUniqueId(LDAPConnection connection,
                                                   UnboundLDAPConfig config,
                                                   byte[] uniqueId) throws LDAPException {
        return searchUser(connection, config, Filter.createEqualityFilter(config.userUniqueIdAttribute(), uniqueId));
    }

    private Optional searchUser(LDAPConnection connection,
                                          UnboundLDAPConfig config,
                                          Filter filter) throws LDAPException {
        final ImmutableSet allAttributes = ImmutableSet.builder()
                .add("userPrincipalName") // TODO: This is ActiveDirectory specific - Do we need this here?
                .add("userAccountControl")
                .addAll(config.emailAttributes())
                .add(config.userUniqueIdAttribute())
                .add(config.userNameAttribute())
                .add(config.userFullNameAttribute())
                .build();

        final ImmutableList result = search(connection, config.userSearchBase(), filter, config.userUniqueIdAttribute(), allAttributes);

        if (result.size() > 1) {
            LOG.warn("Found more than one user for <{}> in search base <{}> - Using the first one", filter.toString(), config.userSearchBase());
        }

        return result.stream().findFirst()
                .map(entry -> createLDAPUser(config, entry));
    }

    public boolean authenticate(LDAPConnection connection, String bindDn, EncryptedValue password) throws LDAPException {
        checkArgument(!isNullOrEmpty(bindDn), "Binding with empty principal is forbidden.");
        checkArgument(password != null, "Binding with null credentials is forbidden.");
        checkArgument(password.isSet(), "Binding with empty credentials is forbidden.");

        final SimpleBindRequest bindRequest = new SimpleBindRequest(bindDn, encryptedValueService.decrypt(password));
        LOG.trace("Re-binding with DN <{}> using password", bindDn);

        try {
            final BindResult bind = connection.bind(bindRequest);

            if (!bind.getResultCode().equals(ResultCode.SUCCESS)) {
                LOG.trace("Re-binding DN <{}> failed", bindDn);
                throw new RuntimeException(bind.toString());
            }
            final boolean authenticated = connection.getLastBindRequest().equals(bindRequest);
            LOG.trace("Binding DN <{}> did not throw, connection authenticated: {}", bindDn, authenticated);
            return authenticated;
        } catch (LDAPBindException e) {
            LOG.trace("Re-binding DN <{}> failed", bindDn);
            return false;
        }
    }

    public LDAPEntry createLDAPEntry(Entry entry, String uniqueIdAttribute) {
        requireNonNull(entry, "entry cannot be null");
        checkArgument(!isBlank(uniqueIdAttribute), "uniqueIdAttribute cannot be blank");

        final LDAPEntry.Builder ldapEntryBuilder = LDAPEntry.builder();

        // Always set the proper DN for the entry
        ldapEntryBuilder.dn(entry.getDN());

        // Always require and set the unique ID attribute
        final byte[] uniqueId = requireNonNull(
                entry.getAttributeValueBytes(uniqueIdAttribute),
                uniqueIdAttribute + " attribute cannot be null"
        );
        ldapEntryBuilder.base64UniqueId(Base64.encode(uniqueId));

        if (entry.getObjectClassValues() != null) {
            ldapEntryBuilder.objectClasses(Arrays.asList(entry.getObjectClassValues()));
        }

        for (final Attribute attribute : entry.getAttributes()) {
            // No need to add the objectClass attribute to the attribute map, we already make it available
            // in LDAPEntry#objectClasses
            if (OBJECT_CLASS_ATTRIBUTE.equalsIgnoreCase(attribute.getBaseName())) {
                continue;
            }

            if (attribute.needsBase64Encoding()) {
                for (final byte[] value : attribute.getValueByteArrays()) {
                    if (isValidUTF8(value)) {
                        ldapEntryBuilder.addAttribute(attribute.getBaseName(), toUTF8String(value));
                    } else {
                        ldapEntryBuilder.addAttribute(attribute.getBaseName(), Base64.encode(value));
                    }
                }
            } else {
                for (final String value : attribute.getValues()) {
                    ldapEntryBuilder.addAttribute(attribute.getBaseName(), value);
                }
            }
        }

        return ldapEntryBuilder.build();
    }

    public LDAPUser createLDAPUser(UnboundLDAPConfig config, Entry entry) {
        return createLDAPUser(config, createLDAPEntry(entry, config.userUniqueIdAttribute()));
    }

    public LDAPUser createLDAPUser(UnboundLDAPConfig config, LDAPEntry ldapEntry) {
        final String username = ldapEntry.nonBlankAttribute(config.userNameAttribute());
        final String emailValue = config.emailAttributes().stream()
                .filter(attr -> ldapEntry.firstAttributeValue(attr).isPresent())
                .map(attr -> ldapEntry.nonBlankAttribute(attr))
                .findFirst().orElse("[email protected]");
        return LDAPUser.builder()
                .base64UniqueId(ldapEntry.base64UniqueId())
                .accountIsEnabled(findAccountIsEnabled(ldapEntry))
                .username(username)
                .fullName(ldapEntry.firstAttributeValue(config.userFullNameAttribute()).orElse(username))
                .email(emailValue)
                .entry(ldapEntry)
                .build();
    }

    private boolean findAccountIsEnabled(LDAPEntry ldapEntry) {
        final Optional control = ldapEntry.firstAttributeValue("userAccountControl");

        // No field present. Assume account is enabled
        if (!control.isPresent()) {
            return true;
        }
        final Integer userAccountControl = Ints.tryParse(control.get());
        if (userAccountControl == null) {
            LOG.warn("Ignoring non-parseable userAccountControl value");
            return true;
        }
        return !ADUserAccountControl.create(userAccountControl).accountIsDisabled();
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy