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