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",
195                    Integer.toString(serverConfig.getPoolingTimeout())); // 1 min by default
196        }
197
198        if (!serverConfig.isVerifyServerCert() && serverConfig.useSsl) {
199            props.put("java.naming.ldap.factory.socket",
200                    "org.nuxeo.ecm.directory.ldap.LDAPDirectory$TrustingSSLSocketFactory");
201        }
202
203        return props;
204    }
205
206    /**
207     * Sets a System property, except if it's already set, to allow for external configuration.
208     */
209    protected void setSystemProperty(String key, String value) {
210        if (System.getProperty(key) == null) {
211            System.setProperty(key, value);
212        }
213    }
214
215    public Properties getContextProperties() {
216        return contextProperties;
217    }
218
219    /**
220     * Search controls that only fetch attributes defined by the schema
221     *
222     * @return common search controls to use for all LDAP search queries
223     */
224    protected SearchControls computeSearchControls() throws DirectoryException {
225        LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
226        SearchControls scts = new SearchControls();
227        // respect the scope of the configuration
228        scts.setSearchScope(ldapDirectoryDesc.getSearchScope());
229
230        // only fetch attributes that are defined in the schema or needed to
231        // compute LDAPReferences
232        Set<String> attrs = new HashSet<>();
233        for (String fieldName : getSchemaFieldMap().keySet()) {
234            if (!references.containsKey(fieldName)) {
235                attrs.add(fieldMapper.getBackendField(fieldName));
236            }
237        }
238        attrs.add("objectClass");
239
240        for (Reference reference : getReferences()) {
241            if (reference instanceof LDAPReference) {
242                LDAPReference ldapReference = (LDAPReference) reference;
243                attrs.add(ldapReference.getStaticAttributeId(fieldMapper));
244                attrs.add(ldapReference.getDynamicAttributeId());
245
246                // Add Dynamic Reference attributes filtering
247                for (LDAPDynamicReferenceDescriptor dynAtt : ldapReference.getDynamicAttributes()) {
248                    attrs.add(dynAtt.baseDN);
249                    attrs.add(dynAtt.filter);
250                }
251
252            }
253        }
254
255        if (getPasswordField() != null) {
256            // never try to fetch the password
257            attrs.remove(getPasswordField());
258        }
259
260        scts.setReturningAttributes(attrs.toArray(new String[attrs.size()]));
261
262        scts.setCountLimit(ldapDirectoryDesc.getQuerySizeLimit());
263        scts.setTimeLimit(ldapDirectoryDesc.getQueryTimeLimit());
264
265        return scts;
266    }
267
268    public SearchControls getSearchControls() {
269        return getSearchControls(false);
270    }
271
272    public SearchControls getSearchControls(boolean fetchAllAttributes) {
273        if (fetchAllAttributes) {
274            // build a new ftcs instance with no attribute filtering
275            LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
276            SearchControls scts = new SearchControls();
277            scts.setSearchScope(ldapDirectoryDesc.getSearchScope());
278            return scts;
279        } else {
280            // return the precomputed scts instance
281            return searchControls;
282        }
283    }
284
285    protected DirContext createContext() throws DirectoryException {
286        try {
287            /*
288             * Dynamic server list requires re-computation on each access
289             */
290            String serverName = getDescriptor().getServerName();
291            if (StringUtils.isEmpty(serverName)) {
292                throw new DirectoryException("server configuration is missing for directory " + getName());
293            }
294            LDAPServerDescriptor serverConfig = getServer();
295            if (serverConfig.isDynamicServerList()) {
296                String ldapUrls = serverConfig.getLdapUrls();
297                contextProperties.put(Context.PROVIDER_URL, ldapUrls);
298            }
299            return new InitialDirContext(contextProperties);
300        } catch (NamingException e) {
301            throw new DirectoryException("Cannot connect to LDAP directory '" + getName() + "': " + e.getMessage(), e);
302        }
303    }
304
305    /**
306     * @since 5.7
307     * @return ldap server descriptor bound to this directory
308     */
309    public LDAPServerDescriptor getServer() {
310        return factory.getServer(getDescriptor().getServerName());
311    }
312
313    @Override
314    public Session getSession() throws DirectoryException {
315        initLDAPConfigIfNeeded();
316        Session session = new LDAPSession(this);
317        addSession(session);
318        return session;
319    }
320
321    public String getBaseFilter() {
322        // NXP-2461: always add control on id field in base filter
323        String idField = getIdField();
324        String idAttribute = getFieldMapper().getBackendField(idField);
325        String idFilter = String.format("(%s=*)", idAttribute);
326        if (baseFilter != null && !"".equals(baseFilter)) {
327            if (baseFilter.startsWith("(")) {
328                return String.format("(&%s%s)", baseFilter, idFilter);
329            } else {
330                return String.format("(&(%s)%s)", baseFilter, idFilter);
331            }
332        } else {
333            return idFilter;
334        }
335    }
336
337    protected ContextProvider getTestServer() {
338        return testServer;
339    }
340
341    public void setTestServer(ContextProvider testServer) {
342        this.testServer = testServer;
343    }
344
345    /**
346     * SSLSocketFactory implementation that verifies all certificates.
347     */
348    public static class TrustingSSLSocketFactory extends SSLSocketFactory {
349
350        private SSLSocketFactory factory;
351
352        /**
353         * Create a new SSLSocketFactory that creates a Socket regardless of the certificate used.
354         */
355        public TrustingSSLSocketFactory() {
356            try {
357                SSLContext sslContext = SSLContext.getInstance("TLS");
358                sslContext.init(null, new TrustManager[] { new TrustingX509TrustManager() }, new SecureRandom());
359                factory = sslContext.getSocketFactory();
360            } catch (NoSuchAlgorithmException nsae) {
361                throw new RuntimeException("Unable to initialize the SSL context:  ", nsae);
362            } catch (KeyManagementException kme) {
363                throw new RuntimeException("Unable to register a trust manager:  ", kme);
364            }
365        }
366
367        /**
368         * TrustingSSLSocketFactoryHolder is loaded on the first execution of TrustingSSLSocketFactory.getDefault() or
369         * the first access to TrustingSSLSocketFactoryHolder.INSTANCE, not before.
370         */
371        private static class TrustingSSLSocketFactoryHolder {
372            public static final TrustingSSLSocketFactory INSTANCE = new TrustingSSLSocketFactory();
373        }
374
375        public static SocketFactory getDefault() {
376            return TrustingSSLSocketFactoryHolder.INSTANCE;
377        }
378
379        @Override
380        public String[] getDefaultCipherSuites() {
381            return factory.getDefaultCipherSuites();
382        }
383
384        @Override
385        public String[] getSupportedCipherSuites() {
386            return factory.getSupportedCipherSuites();
387        }
388
389        @Override
390        public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
391            return factory.createSocket(s, host, port, autoClose);
392        }
393
394        @Override
395        public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
396            return factory.createSocket(host, port);
397        }
398
399        @Override
400        public Socket createSocket(InetAddress host, int port) throws IOException {
401            return factory.createSocket(host, port);
402        }
403
404        @Override
405        public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
406                throws IOException, UnknownHostException {
407            return factory.createSocket(host, port, localHost, localPort);
408        }
409
410        @Override
411        public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
412                throws IOException {
413            return factory.createSocket(address, port, localAddress, localPort);
414        }
415
416        /**
417         * Insecurely trusts everyone.
418         */
419        private class TrustingX509TrustManager implements X509TrustManager {
420
421            @Override
422            public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
423                return;
424            }
425
426            @Override
427            public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
428                return;
429            }
430
431            @Override
432            public X509Certificate[] getAcceptedIssuers() {
433                return new java.security.cert.X509Certificate[0];
434            }
435        }
436
437    }
438
439}