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
152        log.debug(String.format("initialized LDAP directory %s with fields [%s] and references [%s]", config.getName(),
153                StringUtils.join(schemaFieldMap.keySet().toArray(), ", "),
154                StringUtils.join(references.keySet().toArray(), ", ")));
155    }
156
157    /**
158     * @return connection parameters to use for all LDAP queries
159     */
160    protected Properties computeContextProperties() throws DirectoryException {
161        // Initialization of LDAP connection parameters from parameters
162        // registered in the LDAP "server" extension point
163        Properties props = new Properties();
164        LDAPServerDescriptor serverConfig = getServer();
165
166        if (null == serverConfig) {
167            throw new DirectoryException("LDAP server configuration not found: " + config.getServerName());
168        }
169
170        props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
171
172        /*
173         * Get initial connection URLs, dynamic URLs may cause the list to be updated when creating the session
174         */
175        String ldapUrls = serverConfig.getLdapUrls();
176        if (serverConfig.getLdapUrls() == null || config.getSchemaName().equals("")) {
177            throw new DirectoryException("Server LDAP URL configuration is missing for directory " + config.getName());
178        }
179        props.put(Context.PROVIDER_URL, ldapUrls);
180
181        // define how referrals are handled
182        if (!getConfig().followReferrals) {
183            props.put(Context.REFERRAL, "ignore");
184        } else {
185            // this is the default mode
186            props.put(Context.REFERRAL, "follow");
187        }
188
189        /*
190         * SSL Connections do not work with connection timeout property
191         */
192        if (serverConfig.getConnectionTimeout() > -1) {
193            if (!serverConfig.useSsl()) {
194                props.put("com.sun.jndi.ldap.connect.timeout", Integer.toString(serverConfig.getConnectionTimeout()));
195            } else {
196                log.warn("SSL connections do not operate correctly"
197                        + " when used with the connection timeout parameter, disabling timout");
198            }
199        }
200
201        String bindDn = serverConfig.getBindDn();
202        if (bindDn != null) {
203            // Authenticated connection
204            props.put(Context.SECURITY_PRINCIPAL, bindDn);
205            props.put(Context.SECURITY_CREDENTIALS, serverConfig.getBindPassword());
206        }
207
208        if (serverConfig.isPoolingEnabled()) {
209            // Enable connection pooling
210            props.put("com.sun.jndi.ldap.connect.pool", "true");
211            props.put("com.sun.jndi.ldap.connect.pool.protocol", "plain ssl");
212            props.put("com.sun.jndi.ldap.connect.pool.authentication", "none simple DIGEST-MD5");
213            props.put("com.sun.jndi.ldap.connect.pool.timeout", "1800000"); // 30
214            // min
215        }
216
217        if (!serverConfig.isVerifyServerCert() && serverConfig.useSsl) {
218            props.put("java.naming.ldap.factory.socket",
219                    "org.nuxeo.ecm.directory.ldap.LDAPDirectory$TrustingSSLSocketFactory");
220        }
221
222        return props;
223    }
224
225    public Properties getContextProperties() {
226        return contextProperties;
227    }
228
229    /**
230     * Search controls that only fetch attributes defined by the schema
231     *
232     * @return common search controls to use for all LDAP search queries
233     * @throws DirectoryException
234     */
235    protected SearchControls computeSearchControls() throws DirectoryException {
236        SearchControls scts = new SearchControls();
237        // respect the scope of the configuration
238        scts.setSearchScope(config.getSearchScope());
239
240        // only fetch attributes that are defined in the schema or needed to
241        // compute LDAPReferences
242        Set<String> attrs = new HashSet<String>();
243        for (String fieldName : schemaFieldMap.keySet()) {
244            if (!references.containsKey(fieldName)) {
245                attrs.add(fieldMapper.getBackendField(fieldName));
246            }
247        }
248        attrs.add("objectClass");
249
250        for (Reference reference : getReferences()) {
251            if (reference instanceof LDAPReference) {
252                LDAPReference ldapReference = (LDAPReference) reference;
253                attrs.add(ldapReference.getStaticAttributeId(fieldMapper));
254                attrs.add(ldapReference.getDynamicAttributeId());
255
256                // Add Dynamic Reference attributes filtering
257                for (LDAPDynamicReferenceDescriptor dynAtt : ldapReference.getDynamicAttributes()) {
258                    attrs.add(dynAtt.baseDN);
259                    attrs.add(dynAtt.filter);
260                }
261
262            }
263        }
264
265        if (config.getPasswordField() != null) {
266            // never try to fetch the password
267            attrs.remove(config.getPasswordField());
268        }
269
270        scts.setReturningAttributes(attrs.toArray(new String[attrs.size()]));
271
272        scts.setCountLimit(config.getQuerySizeLimit());
273        scts.setTimeLimit(config.getQueryTimeLimit());
274
275        return scts;
276    }
277
278    public SearchControls getSearchControls() {
279        return getSearchControls(false);
280    }
281
282    public SearchControls getSearchControls(boolean fetchAllAttributes) {
283        if (fetchAllAttributes) {
284            // build a new ftcs instance with no attribute filtering
285            SearchControls scts = new SearchControls();
286            scts.setSearchScope(config.getSearchScope());
287            return scts;
288        } else {
289            // return the precomputed scts instance
290            return searchControls;
291        }
292    }
293
294    protected DirContext createContext() throws DirectoryException {
295        try {
296            /*
297             * Dynamic server list requires re-computation on each access
298             */
299            String serverName = config.getServerName();
300            if (serverName == null || serverName.equals("")) {
301                throw new DirectoryException("server configuration is missing for directory " + config.getName());
302            }
303            LDAPServerDescriptor serverConfig = getServer();
304            if (serverConfig.isDynamicServerList()) {
305                String ldapUrls = serverConfig.getLdapUrls();
306                contextProperties.put(Context.PROVIDER_URL, ldapUrls);
307            }
308            return new InitialDirContext(contextProperties);
309        } catch (NamingException e) {
310            throw new DirectoryException("Cannot connect to LDAP directory '" + getName() + "': " + e.getMessage(), e);
311        }
312    }
313
314    @Override
315    public String getName() {
316        return config.getName();
317    }
318
319    @Override
320    public String getSchema() {
321        return config.getSchemaName();
322    }
323
324    @Override
325    public String getParentDirectory() {
326        return null; // no parent directories are specified for LDAP
327    }
328
329    @Override
330    public String getIdField() {
331        return config.getIdField();
332    }
333
334    @Override
335    public String getPasswordField() {
336        return config.getPasswordField();
337    }
338
339    /**
340     * @since 5.7
341     * @return ldap server descriptor bound to this directory
342     */
343    public LDAPServerDescriptor getServer() {
344        return factory.getServer(config.getServerName());
345    }
346
347    @Override
348    public Session getSession() throws DirectoryException {
349        if (schemaFieldMap == null) {
350            initLDAPConfig();
351        }
352        DirContext context;
353        if (testServer != null) {
354            context = testServer.getContext();
355        } else {
356            context = createContext();
357        }
358        Session session = new LDAPSession(this, context);
359        addSession(session);
360        return session;
361    }
362
363    public String getBaseFilter() {
364        // NXP-2461: always add control on id field in base filter
365        String idField = getIdField();
366        DirectoryFieldMapper fieldMapper = getFieldMapper();
367        String idAttribute = fieldMapper.getBackendField(idField);
368        String idFilter = String.format("(%s=*)", idAttribute);
369        if (baseFilter != null && !"".equals(baseFilter)) {
370            if (baseFilter.startsWith("(")) {
371                return String.format("(&%s%s)", baseFilter, idFilter);
372            } else {
373                return String.format("(&(%s)%s)", baseFilter, idFilter);
374            }
375        } else {
376            return idFilter;
377        }
378    }
379
380    public LDAPDirectoryDescriptor getConfig() {
381        return config;
382    }
383
384    public Map<String, Field> getSchemaFieldMap() {
385        return schemaFieldMap;
386    }
387
388    /**
389     * Get the value of the field passwordHashAlgorithm
390     *
391     * @return The value
392     * @since 5.9.3
393     */
394    public String getPasswordHashAlgorithmField() {
395        return config.getPasswordHashAlgorithmField();
396    }
397
398    public void setTestServer(ContextProvider testServer) {
399        this.testServer = testServer;
400    }
401
402    /**
403     * SSLSocketFactory implementation that verifies all certificates.
404     */
405    public static class TrustingSSLSocketFactory extends SSLSocketFactory {
406
407        private SSLSocketFactory factory;
408
409        /**
410         * Create a new SSLSocketFactory that creates a Socket regardless of the certificate used.
411         *
412         * @throws SSLException if initialization fails.
413         */
414        public TrustingSSLSocketFactory() {
415            try {
416                SSLContext sslContext = SSLContext.getInstance("TLS");
417                sslContext.init(null, new TrustManager[] { new TrustingX509TrustManager() }, new SecureRandom());
418                factory = sslContext.getSocketFactory();
419            } catch (NoSuchAlgorithmException nsae) {
420                throw new RuntimeException("Unable to initialize the SSL context:  ", nsae);
421            } catch (KeyManagementException kme) {
422                throw new RuntimeException("Unable to register a trust manager:  ", kme);
423            }
424        }
425
426        /**
427         * TrustingSSLSocketFactoryHolder is loaded on the first execution of TrustingSSLSocketFactory.getDefault() or
428         * the first access to TrustingSSLSocketFactoryHolder.INSTANCE, not before.
429         */
430        private static class TrustingSSLSocketFactoryHolder {
431            public static final TrustingSSLSocketFactory INSTANCE = new TrustingSSLSocketFactory();
432        }
433
434        public static SocketFactory getDefault() {
435            return TrustingSSLSocketFactoryHolder.INSTANCE;
436        }
437
438        @Override
439        public String[] getDefaultCipherSuites() {
440            return factory.getDefaultCipherSuites();
441        }
442
443        @Override
444        public String[] getSupportedCipherSuites() {
445            return factory.getSupportedCipherSuites();
446        }
447
448        @Override
449        public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
450            return factory.createSocket(s, host, port, autoClose);
451        }
452
453        @Override
454        public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
455            return factory.createSocket(host, port);
456        }
457
458        @Override
459        public Socket createSocket(InetAddress host, int port) throws IOException {
460            return factory.createSocket(host, port);
461        }
462
463        @Override
464        public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException,
465                UnknownHostException {
466            return factory.createSocket(host, port, localHost, localPort);
467        }
468
469        @Override
470        public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
471                throws IOException {
472            return factory.createSocket(address, port, localAddress, localPort);
473        }
474
475        /**
476         * Insecurely trusts everyone.
477         */
478        private class TrustingX509TrustManager implements X509TrustManager {
479
480            @Override
481            public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
482                return;
483            }
484
485            @Override
486            public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
487                return;
488            }
489
490            @Override
491            public X509Certificate[] getAcceptedIssuers() {
492                return new java.security.cert.X509Certificate[0];
493            }
494        }
495
496    }
497
498}