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