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    protected SearchControls searchControls;
079
080    protected final LDAPDirectoryFactory factory;
081
082    protected String baseFilter;
083
084    // the following attribute is only used for testing purpose
085    protected ContextProvider testServer;
086
087    public LDAPDirectory(LDAPDirectoryDescriptor descriptor) {
088        super(descriptor, LDAPReference.class);
089        if (StringUtils.isEmpty(descriptor.getSearchBaseDn())) {
090            throw new DirectoryException("searchBaseDn configuration is missing for directory " + getName());
091        }
092        factory = Framework.getService(LDAPDirectoryFactory.class);
093    }
094
095    @Override
096    public LDAPDirectoryDescriptor getDescriptor() {
097        return (LDAPDirectoryDescriptor) descriptor;
098    }
099
100    @Override
101    public List<Reference> getReferences(String referenceFieldName) {
102        if (schemaFieldMap == null) {
103            initLDAPConfig();
104        }
105        return references.get(referenceFieldName);
106    }
107
108    /**
109     * Lazy init method for ldap config
110     *
111     * @since 6.0
112     */
113    protected void initLDAPConfig() {
114        LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
115        // computing attributes that will be useful for all sessions
116        SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
117        Schema schema = schemaManager.getSchema(getSchema());
118        if (schema == null) {
119            throw new DirectoryException(getSchema() + " is not a registered schema");
120        }
121        schemaFieldMap = new LinkedHashMap<>();
122        for (Field f : schema.getFields()) {
123            schemaFieldMap.put(f.getName().getLocalName(), f);
124        }
125
126        // init field mapper before search fields
127        fieldMapper = new DirectoryFieldMapper(ldapDirectoryDesc.fieldMapping);
128        contextProperties = computeContextProperties();
129        baseFilter = ldapDirectoryDesc.getAggregatedSearchFilter();
130
131        // register the references
132        addReferences(ldapDirectoryDesc.getLdapReferences());
133
134        // register the search controls after having registered the references
135        // since the list of attributes to fetch my depend on registered
136        // LDAPReferences
137        searchControls = computeSearchControls();
138
139        log.debug(String.format("initialized LDAP directory %s with fields [%s] and references [%s]", getName(),
140                StringUtils.join(schemaFieldMap.keySet().toArray(), ", "),
141                StringUtils.join(references.keySet().toArray(), ", ")));
142    }
143
144    /**
145     * @return connection parameters to use for all LDAP queries
146     */
147    protected Properties computeContextProperties() throws DirectoryException {
148        LDAPDirectoryDescriptor ldapDirectoryDesc = getDescriptor();
149        // Initialization of LDAP connection parameters from parameters
150        // registered in the LDAP "server" extension point
151        Properties props = new Properties();
152        LDAPServerDescriptor serverConfig = getServer();
153
154        if (null == serverConfig) {
155            throw new DirectoryException("LDAP server configuration not found: " + ldapDirectoryDesc.getServerName());
156        }
157
158        props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
159
160        /*
161         * Get initial connection URLs, dynamic URLs may cause the list to be updated when creating the session
162         */
163        String ldapUrls = serverConfig.getLdapUrls();
164        if (ldapUrls == null) {
165            throw new DirectoryException("Server LDAP URL configuration is missing for directory " + getName());
166        }
167        props.put(Context.PROVIDER_URL, ldapUrls);
168
169        // define how referrals are handled
170        if (!getDescriptor().getFollowReferrals()) {
171            props.put(Context.REFERRAL, "ignore");
172        } else {
173            // this is the default mode
174            props.put(Context.REFERRAL, "follow");
175        }
176
177        /*
178         * SSL Connections do not work with connection timeout property
179         */
180        if (serverConfig.getConnectionTimeout() > -1) {
181            if (!serverConfig.useSsl()) {
182                props.put("com.sun.jndi.ldap.connect.timeout", Integer.toString(serverConfig.getConnectionTimeout()));
183            } else {
184                log.warn("SSL connections do not operate correctly"
185                        + " when used with the connection timeout parameter, disabling timout");
186            }
187        }
188
189        String bindDn = serverConfig.getBindDn();
190        if (bindDn != null) {
191            // Authenticated connection
192            props.put(Context.SECURITY_PRINCIPAL, bindDn);
193            props.put(Context.SECURITY_CREDENTIALS, serverConfig.getBindPassword());
194        }
195
196        if (serverConfig.isPoolingEnabled()) {
197            // Enable connection pooling
198            props.put("com.sun.jndi.ldap.connect.pool", "true");
199            props.put("com.sun.jndi.ldap.connect.pool.protocol", "plain ssl");
200            props.put("com.sun.jndi.ldap.connect.pool.authentication", "none simple DIGEST-MD5");
201            props.put("com.sun.jndi.ldap.connect.pool.timeout", "1800000"); // 30
202            // min
203        }
204
205        if (!serverConfig.isVerifyServerCert() && serverConfig.useSsl) {
206            props.put("java.naming.ldap.factory.socket",
207                    "org.nuxeo.ecm.directory.ldap.LDAPDirectory$TrustingSSLSocketFactory");
208        }
209
210        return props;
211    }
212
213    public Properties getContextProperties() {
214        return contextProperties;
215    }
216
217    /**
218     * Search controls that only fetch attributes defined by the schema
219     *
220     * @return common search controls to use for all LDAP search queries
221     * @throws DirectoryException
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 : schemaFieldMap.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        if (schemaFieldMap == null) {
315            initLDAPConfig();
316        }
317        DirContext context;
318        if (testServer != null) {
319            context = testServer.getContext();
320        } else {
321            context = createContext();
322        }
323        Session session = new LDAPSession(this, context);
324        addSession(session);
325        return session;
326    }
327
328    public String getBaseFilter() {
329        // NXP-2461: always add control on id field in base filter
330        String idField = getIdField();
331        String idAttribute = getFieldMapper().getBackendField(idField);
332        String idFilter = String.format("(%s=*)", idAttribute);
333        if (baseFilter != null && !"".equals(baseFilter)) {
334            if (baseFilter.startsWith("(")) {
335                return String.format("(&%s%s)", baseFilter, idFilter);
336            } else {
337                return String.format("(&(%s)%s)", baseFilter, idFilter);
338            }
339        } else {
340            return idFilter;
341        }
342    }
343
344    public void setTestServer(ContextProvider testServer) {
345        this.testServer = testServer;
346    }
347
348    /**
349     * SSLSocketFactory implementation that verifies all certificates.
350     */
351    public static class TrustingSSLSocketFactory extends SSLSocketFactory {
352
353        private SSLSocketFactory factory;
354
355        /**
356         * Create a new SSLSocketFactory that creates a Socket regardless of the certificate used.
357         */
358        public TrustingSSLSocketFactory() {
359            try {
360                SSLContext sslContext = SSLContext.getInstance("TLS");
361                sslContext.init(null, new TrustManager[] { new TrustingX509TrustManager() }, new SecureRandom());
362                factory = sslContext.getSocketFactory();
363            } catch (NoSuchAlgorithmException nsae) {
364                throw new RuntimeException("Unable to initialize the SSL context:  ", nsae);
365            } catch (KeyManagementException kme) {
366                throw new RuntimeException("Unable to register a trust manager:  ", kme);
367            }
368        }
369
370        /**
371         * TrustingSSLSocketFactoryHolder is loaded on the first execution of TrustingSSLSocketFactory.getDefault() or
372         * the first access to TrustingSSLSocketFactoryHolder.INSTANCE, not before.
373         */
374        private static class TrustingSSLSocketFactoryHolder {
375            public static final TrustingSSLSocketFactory INSTANCE = new TrustingSSLSocketFactory();
376        }
377
378        public static SocketFactory getDefault() {
379            return TrustingSSLSocketFactoryHolder.INSTANCE;
380        }
381
382        @Override
383        public String[] getDefaultCipherSuites() {
384            return factory.getDefaultCipherSuites();
385        }
386
387        @Override
388        public String[] getSupportedCipherSuites() {
389            return factory.getSupportedCipherSuites();
390        }
391
392        @Override
393        public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
394            return factory.createSocket(s, host, port, autoClose);
395        }
396
397        @Override
398        public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
399            return factory.createSocket(host, port);
400        }
401
402        @Override
403        public Socket createSocket(InetAddress host, int port) throws IOException {
404            return factory.createSocket(host, port);
405        }
406
407        @Override
408        public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException,
409                UnknownHostException {
410            return factory.createSocket(host, port, localHost, localPort);
411        }
412
413        @Override
414        public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
415                throws IOException {
416            return factory.createSocket(address, port, localAddress, localPort);
417        }
418
419        /**
420         * Insecurely trusts everyone.
421         */
422        private class TrustingX509TrustManager implements X509TrustManager {
423
424            @Override
425            public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
426                return;
427            }
428
429            @Override
430            public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
431                return;
432            }
433
434            @Override
435            public X509Certificate[] getAcceptedIssuers() {
436                return new java.security.cert.X509Certificate[0];
437            }
438        }
439
440    }
441
442}