001/*
002 * (C) Copyright 2006-2007 Nuxeo SAS (http://nuxeo.com/) and contributors.
003 *
004 * All rights reserved. This program and the accompanying materials
005 * are made available under the terms of the GNU Lesser General Public License
006 * (LGPL) version 2.1 which accompanies this distribution, and is available at
007 * http://www.gnu.org/licenses/lgpl.html
008 *
009 * This library is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
012 * Lesser General Public License for more details.
013 *
014 * Contributors:
015 *     Nuxeo - initial API and implementation
016 *
017 * $Id$
018 */
019
020package org.nuxeo.ecm.directory.ldap;
021
022import java.io.IOException;
023import java.net.InetAddress;
024import java.net.Socket;
025import java.net.UnknownHostException;
026import java.security.KeyManagementException;
027import java.security.NoSuchAlgorithmException;
028import java.security.SecureRandom;
029import java.security.cert.CertificateException;
030import java.security.cert.X509Certificate;
031import java.util.HashSet;
032import java.util.LinkedHashMap;
033import java.util.List;
034import java.util.Map;
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.SSLException;
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 final LDAPDirectoryDescriptor config;
077
078    protected Properties contextProperties;
079
080    protected SearchControls searchControls;
081
082    protected Map<String, Field> schemaFieldMap;
083
084    protected final LDAPDirectoryFactory factory;
085
086    protected String baseFilter;
087
088    // the following attribute is only used for testing purpose
089    protected ContextProvider testServer;
090
091    public LDAPDirectory(LDAPDirectoryDescriptor config) {
092        super(config.name);
093        this.config = config;
094        factory = (LDAPDirectoryFactory) Framework.getRuntime().getComponent(LDAPDirectoryFactory.NAME);
095
096        if (config.getIdField() == null || config.getIdField().equals("")) {
097            throw new DirectoryException("idField configuration is missing for directory " + config.getName());
098        }
099        if (config.getSchemaName() == null || config.getSchemaName().equals("")) {
100            throw new DirectoryException("schema configuration is missing for directory " + config.getName());
101        }
102        if (config.getSearchBaseDn() == null || config.getSearchBaseDn().equals("")) {
103            throw new DirectoryException("searchBaseDn configuration is missing for directory " + config.getName());
104        }
105
106    }
107
108    @Override
109    public List<Reference> getReferences(String referenceFieldName) {
110        if(schemaFieldMap == null)
111        {
112            initLDAPConfig();
113        }
114        return references.get(referenceFieldName);
115    }
116
117    /**
118     * Lazy init method for ldap config
119     *
120     * @since 6.0
121     */
122    protected void initLDAPConfig() {
123        // computing attributes that will be useful for all sessions
124        SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
125        Schema schema = schemaManager.getSchema(config.getSchemaName());
126        if (schema == null) {
127            throw new DirectoryException(config.getSchemaName() + " is not a registered schema");
128        }
129        schemaFieldMap = new LinkedHashMap<String, Field>();
130        for (Field f : schema.getFields()) {
131            schemaFieldMap.put(f.getName().getLocalName(), f);
132        }
133
134        // init field mapper before search fields
135        fieldMapper = new DirectoryFieldMapper(config.fieldMapping);
136        contextProperties = computeContextProperties();
137        baseFilter = config.getAggregatedSearchFilter();
138
139        // register the references
140        addReferences(config.getInverseReferences());
141        addReferences(config.getLdapReferences());
142
143        // register the search controls after having registered the references
144        // since the list of attributes to fetch my depend on registered
145        // LDAPReferences
146        searchControls = computeSearchControls();
147
148        // cache parameterization
149        cache.setEntryCacheName(config.cacheEntryName);
150        cache.setEntryCacheWithoutReferencesName(config.cacheEntryWithoutReferencesName);
151        cache.setNegativeCaching(config.negativeCaching);
152
153        log.debug(String.format("initialized LDAP directory %s with fields [%s] and references [%s]", config.getName(),
154                StringUtils.join(schemaFieldMap.keySet().toArray(), ", "),
155                StringUtils.join(references.keySet().toArray(), ", ")));
156    }
157
158    /**
159     * @return connection parameters to use for all LDAP queries
160     */
161    protected Properties computeContextProperties() throws DirectoryException {
162        // Initialization of LDAP connection parameters from parameters
163        // registered in the LDAP "server" extension point
164        Properties props = new Properties();
165        LDAPServerDescriptor serverConfig = getServer();
166
167        if (null == serverConfig) {
168            throw new DirectoryException("LDAP server configuration not found: " + config.getServerName());
169        }
170
171        props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
172
173        /*
174         * Get initial connection URLs, dynamic URLs may cause the list to be updated when creating the session
175         */
176        String ldapUrls = serverConfig.getLdapUrls();
177        if (serverConfig.getLdapUrls() == null || config.getSchemaName().equals("")) {
178            throw new DirectoryException("Server LDAP URL configuration is missing for directory " + config.getName());
179        }
180        props.put(Context.PROVIDER_URL, ldapUrls);
181
182        // define how referrals are handled
183        if (!getConfig().followReferrals) {
184            props.put(Context.REFERRAL, "ignore");
185        } else {
186            // this is the default mode
187            props.put(Context.REFERRAL, "follow");
188        }
189
190        /*
191         * SSL Connections do not work with connection timeout property
192         */
193        if (serverConfig.getConnectionTimeout() > -1) {
194            if (!serverConfig.useSsl()) {
195                props.put("com.sun.jndi.ldap.connect.timeout", Integer.toString(serverConfig.getConnectionTimeout()));
196            } else {
197                log.warn("SSL connections do not operate correctly"
198                        + " when used with the connection timeout parameter, disabling timout");
199            }
200        }
201
202        String bindDn = serverConfig.getBindDn();
203        if (bindDn != null) {
204            // Authenticated connection
205            props.put(Context.SECURITY_PRINCIPAL, bindDn);
206            props.put(Context.SECURITY_CREDENTIALS, serverConfig.getBindPassword());
207        }
208
209        if (serverConfig.isPoolingEnabled()) {
210            // Enable connection pooling
211            props.put("com.sun.jndi.ldap.connect.pool", "true");
212            props.put("com.sun.jndi.ldap.connect.pool.protocol", "plain ssl");
213            props.put("com.sun.jndi.ldap.connect.pool.authentication", "none simple DIGEST-MD5");
214            props.put("com.sun.jndi.ldap.connect.pool.timeout", "1800000"); // 30
215            // min
216        }
217
218        if (!serverConfig.isVerifyServerCert() && serverConfig.useSsl) {
219            props.put("java.naming.ldap.factory.socket",
220                    "org.nuxeo.ecm.directory.ldap.LDAPDirectory$TrustingSSLSocketFactory");
221        }
222
223        return props;
224    }
225
226    public Properties getContextProperties() {
227        return contextProperties;
228    }
229
230    /**
231     * Search controls that only fetch attributes defined by the schema
232     *
233     * @return common search controls to use for all LDAP search queries
234     * @throws DirectoryException
235     */
236    protected SearchControls computeSearchControls() throws DirectoryException {
237        SearchControls scts = new SearchControls();
238        // respect the scope of the configuration
239        scts.setSearchScope(config.getSearchScope());
240
241        // only fetch attributes that are defined in the schema or needed to
242        // compute LDAPReferences
243        Set<String> attrs = new HashSet<String>();
244        for (String fieldName : schemaFieldMap.keySet()) {
245            if (!references.containsKey(fieldName)) {
246                attrs.add(fieldMapper.getBackendField(fieldName));
247            }
248        }
249        attrs.add("objectClass");
250
251        for (Reference reference : getReferences()) {
252            if (reference instanceof LDAPReference) {
253                LDAPReference ldapReference = (LDAPReference) reference;
254                attrs.add(ldapReference.getStaticAttributeId(fieldMapper));
255                attrs.add(ldapReference.getDynamicAttributeId());
256
257                // Add Dynamic Reference attributes filtering
258                for (LDAPDynamicReferenceDescriptor dynAtt : ldapReference.getDynamicAttributes()) {
259                    attrs.add(dynAtt.baseDN);
260                    attrs.add(dynAtt.filter);
261                }
262
263            }
264        }
265
266        if (config.getPasswordField() != null) {
267            // never try to fetch the password
268            attrs.remove(config.getPasswordField());
269        }
270
271        scts.setReturningAttributes(attrs.toArray(new String[attrs.size()]));
272
273        scts.setCountLimit(config.getQuerySizeLimit());
274        scts.setTimeLimit(config.getQueryTimeLimit());
275
276        return scts;
277    }
278
279    public SearchControls getSearchControls() {
280        return getSearchControls(false);
281    }
282
283    public SearchControls getSearchControls(boolean fetchAllAttributes) {
284        if (fetchAllAttributes) {
285            // return the precomputed scts instance
286            return searchControls;
287        } else {
288            // build a new ftcs instance with no attribute filtering
289            SearchControls scts = new SearchControls();
290            scts.setSearchScope(config.getSearchScope());
291            scts.setReturningAttributes(new String[] { config.rdnAttribute, config.fieldMapping.get(config.idField) });
292            return scts;
293        }
294    }
295
296    protected DirContext createContext() throws DirectoryException {
297        try {
298            /*
299             * Dynamic server list requires re-computation on each access
300             */
301            String serverName = config.getServerName();
302            if (serverName == null || serverName.equals("")) {
303                throw new DirectoryException("server configuration is missing for directory " + config.getName());
304            }
305            LDAPServerDescriptor serverConfig = getServer();
306            if (serverConfig.isDynamicServerList()) {
307                String ldapUrls = serverConfig.getLdapUrls();
308                contextProperties.put(Context.PROVIDER_URL, ldapUrls);
309            }
310            return new InitialDirContext(contextProperties);
311        } catch (NamingException e) {
312            throw new DirectoryException("Cannot connect to LDAP directory '" + getName() + "': " + e.getMessage(), e);
313        }
314    }
315
316    @Override
317    public String getName() {
318        return config.getName();
319    }
320
321    @Override
322    public String getSchema() {
323        return config.getSchemaName();
324    }
325
326    @Override
327    public String getParentDirectory() {
328        return null; // no parent directories are specified for LDAP
329    }
330
331    @Override
332    public String getIdField() {
333        return config.getIdField();
334    }
335
336    @Override
337    public String getPasswordField() {
338        return config.getPasswordField();
339    }
340
341    /**
342     * @since 5.7
343     * @return ldap server descriptor bound to this directory
344     */
345    public LDAPServerDescriptor getServer() {
346        return factory.getServer(config.getServerName());
347    }
348
349    @Override
350    public Session getSession() throws DirectoryException {
351        if (schemaFieldMap == null) {
352            initLDAPConfig();
353        }
354        DirContext context;
355        if (testServer != null) {
356            context = testServer.getContext();
357        } else {
358            context = createContext();
359        }
360        Session session = new LDAPSession(this, context);
361        addSession(session);
362        return session;
363    }
364
365    public String getBaseFilter() {
366        // NXP-2461: always add control on id field in base filter
367        String idField = getIdField();
368        String idAttribute = getFieldMapper().getBackendField(idField);
369        String idFilter = String.format("(%s=*)", idAttribute);
370        if (baseFilter != null && !"".equals(baseFilter)) {
371            if (baseFilter.startsWith("(")) {
372                return String.format("(&%s%s)", baseFilter, idFilter);
373            } else {
374                return String.format("(&(%s)%s)", baseFilter, idFilter);
375            }
376        } else {
377            return idFilter;
378        }
379    }
380
381    public LDAPDirectoryDescriptor getConfig() {
382        return config;
383    }
384
385    public Map<String, Field> getSchemaFieldMap() {
386        return schemaFieldMap;
387    }
388
389    /**
390     * Get the value of the field passwordHashAlgorithm
391     *
392     * @return The value
393     * @since 5.9.3
394     */
395    public String getPasswordHashAlgorithmField() {
396        return config.getPasswordHashAlgorithmField();
397    }
398
399    public void setTestServer(ContextProvider testServer) {
400        this.testServer = testServer;
401    }
402
403    /**
404     * SSLSocketFactory implementation that verifies all certificates.
405     */
406    public static class TrustingSSLSocketFactory extends SSLSocketFactory {
407
408        private SSLSocketFactory factory;
409
410        /**
411         * Create a new SSLSocketFactory that creates a Socket regardless of the certificate used.
412         *
413         * @throws SSLException if initialization fails.
414         */
415        public TrustingSSLSocketFactory() {
416            try {
417                SSLContext sslContext = SSLContext.getInstance("TLS");
418                sslContext.init(null, new TrustManager[] { new TrustingX509TrustManager() }, new SecureRandom());
419                factory = sslContext.getSocketFactory();
420            } catch (NoSuchAlgorithmException nsae) {
421                throw new RuntimeException("Unable to initialize the SSL context:  ", nsae);
422            } catch (KeyManagementException kme) {
423                throw new RuntimeException("Unable to register a trust manager:  ", kme);
424            }
425        }
426
427        /**
428         * TrustingSSLSocketFactoryHolder is loaded on the first execution of TrustingSSLSocketFactory.getDefault() or
429         * the first access to TrustingSSLSocketFactoryHolder.INSTANCE, not before.
430         */
431        private static class TrustingSSLSocketFactoryHolder {
432            public static final TrustingSSLSocketFactory INSTANCE = new TrustingSSLSocketFactory();
433        }
434
435        public static SocketFactory getDefault() {
436            return TrustingSSLSocketFactoryHolder.INSTANCE;
437        }
438
439        @Override
440        public String[] getDefaultCipherSuites() {
441            return factory.getDefaultCipherSuites();
442        }
443
444        @Override
445        public String[] getSupportedCipherSuites() {
446            return factory.getSupportedCipherSuites();
447        }
448
449        @Override
450        public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
451            return factory.createSocket(s, host, port, autoClose);
452        }
453
454        @Override
455        public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
456            return factory.createSocket(host, port);
457        }
458
459        @Override
460        public Socket createSocket(InetAddress host, int port) throws IOException {
461            return factory.createSocket(host, port);
462        }
463
464        @Override
465        public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException,
466                UnknownHostException {
467            return factory.createSocket(host, port, localHost, localPort);
468        }
469
470        @Override
471        public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
472                throws IOException {
473            return factory.createSocket(address, port, localAddress, localPort);
474        }
475
476        /**
477         * Insecurely trusts everyone.
478         */
479        private class TrustingX509TrustManager implements X509TrustManager {
480
481            @Override
482            public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
483                return;
484            }
485
486            @Override
487            public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
488                return;
489            }
490
491            @Override
492            public X509Certificate[] getAcceptedIssuers() {
493                return new java.security.cert.X509Certificate[0];
494            }
495        }
496
497    }
498
499}