001/*
002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Nuxeo - initial API and implementation
018 *
019 * $Id$
020 */
021
022package org.nuxeo.ecm.directory.ldap;
023
024import java.io.IOException;
025import java.net.InetAddress;
026import java.net.Socket;
027import java.net.UnknownHostException;
028import java.security.KeyManagementException;
029import java.security.NoSuchAlgorithmException;
030import java.security.SecureRandom;
031import java.security.cert.X509Certificate;
032import java.util.ArrayList;
033import java.util.Arrays;
034import java.util.HashSet;
035import java.util.List;
036import java.util.Properties;
037import java.util.Set;
038
039import javax.naming.Context;
040import javax.naming.NamingException;
041import javax.naming.directory.DirContext;
042import javax.naming.directory.InitialDirContext;
043import javax.naming.directory.SearchControls;
044import javax.net.SocketFactory;
045import javax.net.ssl.SSLContext;
046import javax.net.ssl.SSLSocketFactory;
047import javax.net.ssl.TrustManager;
048import javax.net.ssl.X509TrustManager;
049
050import org.apache.commons.lang3.StringUtils;
051import org.apache.commons.logging.Log;
052import org.apache.commons.logging.LogFactory;
053import org.nuxeo.ecm.directory.AbstractDirectory;
054import org.nuxeo.ecm.directory.DirectoryException;
055import org.nuxeo.ecm.directory.DirectoryFieldMapper;
056import org.nuxeo.ecm.directory.Reference;
057import org.nuxeo.runtime.api.Framework;
058
059/**
060 * Implementation of the Directory interface for servers implementing the Lightweight Directory Access Protocol.
061 *
062 * @author ogrisel
063 * @author Robert Browning
064 */
065public class LDAPDirectory extends AbstractDirectory {
066
067    private static final Log log = LogFactory.getLog(LDAPDirectory.class);
068
069    // special field key to be able to read the DN of an LDAP entry
070    public static final String DN_SPECIAL_ATTRIBUTE_KEY = "dn";
071
072    protected Properties contextProperties;
073
074    protected volatile SearchControls idSearchControls;
075
076    // used in double-checked locking for lazy init
077    protected volatile SearchControls searchControls;
078
079    protected final LDAPDirectoryFactory factory;
080
081    protected String baseFilter;
082
083    // the following attribute is only used for testing purpose
084    protected ContextProvider testServer;
085
086    public LDAPDirectory(LDAPDirectoryDescriptor descriptor) {
087        super(descriptor, LDAPReference.class);
088        if (StringUtils.isEmpty(descriptor.getSearchBaseDn())) {
089            throw new DirectoryException("searchBaseDn configuration is missing for directory " + getName());
090        }
091        factory = Framework.getService(LDAPDirectoryFactory.class);
092    }
093
094    @Override
095    public LDAPDirectoryDescriptor getDescriptor() {
096        return (LDAPDirectoryDescriptor) descriptor;
097    }
098
099    @Override
100    protected void addReferences() {
101        super.addReferences();
102        // add backward compat LDAP references
103        Reference[] refs = getDescriptor().getLdapReferences();
104        Arrays.stream(refs).forEach(this::addReference);
105    }
106
107    @Override
108    public List<Reference> getReferences(String referenceFieldName) {
109        return references.get(referenceFieldName);
110    }
111
112    @Override
113    public void initialize() {
114        super.initialize();
115
116        // init field mapper before search fields
117        LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
118        fieldMapper = new DirectoryFieldMapper(ldapDirectoryDesc.fieldMapping);
119        contextProperties = computeContextProperties();
120        baseFilter = ldapDirectoryDesc.getAggregatedSearchFilter();
121
122        // register the search controls after having registered the references
123        // since the list of attributes to fetch my depend on registered
124        // LDAPReferences
125        idSearchControls = computeIdSearchControls();
126        searchControls = computeSearchControls();
127
128        log.debug(String.format("initialized LDAP directory %s with fields [%s] and references [%s]", getName(),
129                StringUtils.join(getSchemaFieldMap().keySet().toArray(), ", "),
130                StringUtils.join(references.keySet().toArray(), ", ")));
131    }
132
133    /**
134     * @return connection parameters to use for all LDAP queries
135     */
136    protected Properties computeContextProperties() {
137        LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
138        // Initialization of LDAP connection parameters from parameters
139        // registered in the LDAP "server" extension point
140        Properties props = new Properties();
141        LDAPServerDescriptor serverConfig = getServer();
142
143        if (null == serverConfig) {
144            throw new DirectoryException("LDAP server configuration not found: " + ldapDirectoryDesc.getServerName());
145        }
146
147        props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
148
149        /*
150         * Get initial connection URLs, dynamic URLs may cause the list to be updated when creating the session
151         */
152        String ldapUrls = serverConfig.getLdapUrls();
153        if (ldapUrls == null) {
154            throw new DirectoryException("Server LDAP URL configuration is missing for directory " + getName());
155        }
156        props.put(Context.PROVIDER_URL, ldapUrls);
157
158        // define how referrals are handled
159        if (!getDescriptor().getFollowReferrals()) {
160            props.put(Context.REFERRAL, "ignore");
161        } else {
162            // this is the default mode
163            props.put(Context.REFERRAL, "follow");
164        }
165
166        /*
167         * SSL Connections do not work with connection timeout property
168         */
169        if (serverConfig.getConnectionTimeout() > -1) {
170            if (!serverConfig.useSsl()) {
171                props.put("com.sun.jndi.ldap.connect.timeout", Integer.toString(serverConfig.getConnectionTimeout()));
172            } else {
173                log.warn("SSL connections do not operate correctly"
174                        + " when used with the connection timeout parameter, disabling timout");
175            }
176        }
177
178        String bindDn = serverConfig.getBindDn();
179        if (bindDn != null) {
180            // Authenticated connection
181            props.put(Context.SECURITY_PRINCIPAL, bindDn);
182            props.put(Context.SECURITY_CREDENTIALS, serverConfig.getBindPassword());
183        }
184
185        if (serverConfig.isPoolingEnabled()) {
186            // Enable connection pooling
187            props.put("com.sun.jndi.ldap.connect.pool", "true");
188            // the rest of the properties controlling pool configuration are system properties!
189            setSystemProperty("com.sun.jndi.ldap.connect.pool.protocol", "plain ssl");
190            setSystemProperty("com.sun.jndi.ldap.connect.pool.authentication", "none simple DIGEST-MD5");
191            setSystemProperty("com.sun.jndi.ldap.connect.pool.timeout",
192                    Integer.toString(serverConfig.getPoolingTimeout())); // 1 min by default
193        }
194
195        if (!serverConfig.isVerifyServerCert() && serverConfig.useSsl) {
196            props.put("java.naming.ldap.factory.socket",
197                    "org.nuxeo.ecm.directory.ldap.LDAPDirectory$TrustingSSLSocketFactory");
198        }
199
200        return props;
201    }
202
203    /**
204     * Sets a System property, except if it's already set, to allow for external configuration.
205     */
206    protected void setSystemProperty(String key, String value) {
207        if (System.getProperty(key) == null) {
208            System.setProperty(key, value);
209        }
210    }
211
212    public Properties getContextProperties() {
213        return contextProperties;
214    }
215
216    protected SearchControls computeIdSearchControls() {
217        LDAPDirectoryDescriptor desc = getDescriptor();
218        SearchControls scts = new SearchControls();
219        scts.setSearchScope(desc.getSearchScope());
220        String idAttr = fieldMapper.getBackendField(getIdField());
221        scts.setReturningAttributes(new String[] { idAttr });
222        scts.setCountLimit(desc.getQuerySizeLimit());
223        scts.setTimeLimit(desc.getQueryTimeLimit());
224        return scts;
225    }
226
227    /**
228     * Search controls that only fetch attributes defined by the schema
229     *
230     * @return common search controls to use for all LDAP search queries
231     */
232    protected SearchControls computeSearchControls() {
233        LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
234        SearchControls scts = new SearchControls();
235        // respect the scope of the configuration
236        scts.setSearchScope(ldapDirectoryDesc.getSearchScope());
237
238        // only fetch attributes that are defined in the schema or needed to
239        // compute LDAPReferences
240        Set<String> attrs = new HashSet<>();
241        for (String fieldName : getSchemaFieldMap().keySet()) {
242            if (!references.containsKey(fieldName)) {
243                attrs.add(fieldMapper.getBackendField(fieldName));
244            }
245        }
246        attrs.add("objectClass");
247
248        for (Reference reference : getReferences()) {
249            if (reference instanceof LDAPReference) {
250                LDAPReference ldapReference = (LDAPReference) reference;
251                attrs.add(ldapReference.getStaticAttributeId(fieldMapper));
252                attrs.add(ldapReference.getDynamicAttributeId());
253
254                // Add Dynamic Reference attributes filtering
255                for (LDAPDynamicReferenceDescriptor dynAtt : ldapReference.getDynamicAttributes()) {
256                    attrs.add(dynAtt.baseDN);
257                    attrs.add(dynAtt.filter);
258                }
259
260            }
261        }
262
263        if (getPasswordField() != null) {
264            // never try to fetch the password
265            attrs.remove(getPasswordField());
266        }
267
268        scts.setReturningAttributes(attrs.toArray(new String[attrs.size()]));
269
270        scts.setCountLimit(ldapDirectoryDesc.getQuerySizeLimit());
271        scts.setTimeLimit(ldapDirectoryDesc.getQueryTimeLimit());
272
273        return scts;
274    }
275
276    /**
277     * Search controls that only fetch the id.
278     *
279     * @since 10.3
280     */
281    public SearchControls getIdSearchControls() {
282        return idSearchControls;
283    }
284
285    public SearchControls getSearchControls() {
286        return getSearchControls(false);
287    }
288
289    public SearchControls getSearchControls(boolean fetchAllAttributes) {
290        if (fetchAllAttributes) {
291            // build a new ftcs instance with no attribute filtering
292            LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
293            SearchControls scts = new SearchControls();
294            scts.setSearchScope(ldapDirectoryDesc.getSearchScope());
295            return scts;
296        } else {
297            // return the precomputed scts instance
298            return searchControls;
299        }
300    }
301
302    protected DirContext createContext() {
303        try {
304            /*
305             * Dynamic server list requires re-computation on each access
306             */
307            String serverName = getDescriptor().getServerName();
308            if (StringUtils.isEmpty(serverName)) {
309                throw new DirectoryException("server configuration is missing for directory " + getName());
310            }
311            LDAPServerDescriptor serverConfig = getServer();
312            if (serverConfig.isDynamicServerList()) {
313                String ldapUrls = serverConfig.getLdapUrls();
314                contextProperties.put(Context.PROVIDER_URL, ldapUrls);
315            }
316            return new InitialDirContext(contextProperties);
317        } catch (NamingException e) {
318            throw new DirectoryException("Cannot connect to LDAP directory '" + getName() + "': " + e.getMessage(), e);
319        }
320    }
321
322    /**
323     * @since 5.7
324     * @return ldap server descriptor bound to this directory
325     */
326    public LDAPServerDescriptor getServer() {
327        return factory.getServer(getDescriptor().getServerName());
328    }
329
330    @Override
331    public LDAPSession getSession() {
332        LDAPSession session = new LDAPSession(this);
333        addSession(session);
334        return session;
335    }
336
337    public String getBaseFilter() {
338        // NXP-2461: always add control on id field in base filter
339        String idField = getIdField();
340        String idAttribute = getFieldMapper().getBackendField(idField);
341        String idFilter = String.format("(%s=*)", idAttribute);
342        if (baseFilter != null && !"".equals(baseFilter)) {
343            if (baseFilter.startsWith("(")) {
344                return String.format("(&%s%s)", baseFilter, idFilter);
345            } else {
346                return String.format("(&(%s)%s)", baseFilter, idFilter);
347            }
348        } else {
349            return idFilter;
350        }
351    }
352
353    public String addBaseFilter(String filter) {
354        List<String> filters = new ArrayList<>();
355        // NXP-2461: always add control on id field in base filter
356        String idFilter = '(' + getFieldMapper().getBackendField(getIdField()) + "=*)";
357        filters.add(idFilter);
358        if (StringUtils.isNotBlank(baseFilter)) {
359            if (!baseFilter.startsWith("(")) {
360                baseFilter = '(' + baseFilter + ')';
361            }
362            filters.add(baseFilter);
363        }
364        if (StringUtils.isNotBlank(filter)) {
365            filters.add(filter);
366        }
367        return "(&" + StringUtils.join(filters, "") + ')';
368    }
369
370    protected ContextProvider getTestServer() {
371        return testServer;
372    }
373
374    public void setTestServer(ContextProvider testServer) {
375        this.testServer = testServer;
376    }
377
378    /**
379     * SSLSocketFactory implementation that verifies all certificates.
380     */
381    public static class TrustingSSLSocketFactory extends SSLSocketFactory {
382
383        private SSLSocketFactory factory;
384
385        /**
386         * Create a new SSLSocketFactory that creates a Socket regardless of the certificate used.
387         */
388        public TrustingSSLSocketFactory() {
389            try {
390                SSLContext sslContext = SSLContext.getDefault();
391                sslContext.init(null, new TrustManager[] { new TrustingX509TrustManager() }, new SecureRandom());
392                factory = sslContext.getSocketFactory();
393            } catch (NoSuchAlgorithmException nsae) {
394                throw new RuntimeException("Unable to initialize the SSL context:  ", nsae);
395            } catch (KeyManagementException kme) {
396                throw new RuntimeException("Unable to register a trust manager:  ", kme);
397            }
398        }
399
400        /**
401         * TrustingSSLSocketFactoryHolder is loaded on the first execution of TrustingSSLSocketFactory.getDefault() or
402         * the first access to TrustingSSLSocketFactoryHolder.INSTANCE, not before.
403         */
404        private static class TrustingSSLSocketFactoryHolder {
405            public static final TrustingSSLSocketFactory INSTANCE = new TrustingSSLSocketFactory();
406        }
407
408        public static SocketFactory getDefault() {
409            return TrustingSSLSocketFactoryHolder.INSTANCE;
410        }
411
412        @Override
413        public String[] getDefaultCipherSuites() {
414            return factory.getDefaultCipherSuites();
415        }
416
417        @Override
418        public String[] getSupportedCipherSuites() {
419            return factory.getSupportedCipherSuites();
420        }
421
422        @Override
423        public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
424            return factory.createSocket(s, host, port, autoClose);
425        }
426
427        @Override
428        public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
429            return factory.createSocket(host, port);
430        }
431
432        @Override
433        public Socket createSocket(InetAddress host, int port) throws IOException {
434            return factory.createSocket(host, port);
435        }
436
437        @Override
438        public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
439                throws IOException, UnknownHostException {
440            return factory.createSocket(host, port, localHost, localPort);
441        }
442
443        @Override
444        public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
445                throws IOException {
446            return factory.createSocket(address, port, localAddress, localPort);
447        }
448
449        /**
450         * Insecurely trusts everyone.
451         */
452        private class TrustingX509TrustManager implements X509TrustManager {
453
454            @Override
455            public void checkClientTrusted(X509Certificate[] arg0, String arg1) { // NOSONAR (NXP-10253)
456            }
457
458            @Override
459            public void checkServerTrusted(X509Certificate[] arg0, String arg1) { // NOSONAR (NXP-10253)
460            }
461
462            @Override
463            public X509Certificate[] getAcceptedIssuers() {
464                return new java.security.cert.X509Certificate[0];
465            }
466        }
467
468    }
469
470}