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