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 */
020package org.nuxeo.ecm.directory.ldap;
021
022import java.util.ArrayList;
023import java.util.Collections;
024import java.util.Enumeration;
025import java.util.HashSet;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Set;
029
030import javax.naming.InvalidNameException;
031import javax.naming.NamingException;
032
033import org.apache.commons.lang.StringUtils;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.apache.directory.shared.ldap.name.LdapDN;
037import org.nuxeo.common.xmap.annotation.XNode;
038import org.nuxeo.common.xmap.annotation.XNodeList;
039import org.nuxeo.common.xmap.annotation.XObject;
040import org.nuxeo.ecm.directory.DirectoryException;
041import org.nuxeo.ecm.directory.ldap.dns.DNSServiceEntry;
042import org.nuxeo.ecm.directory.ldap.dns.DNSServiceResolver;
043import org.nuxeo.ecm.directory.ldap.dns.DNSServiceResolverImpl;
044
045import com.sun.jndi.ldap.LdapURL;
046
047@XObject(value = "server")
048public class LDAPServerDescriptor {
049
050    public static final Log log = LogFactory.getLog(LDAPServerDescriptor.class);
051
052    protected static final String LDAPS_SCHEME = "ldaps";
053
054    protected static final String LDAP_SCHEME = "ldap";
055
056    @XNode("@name")
057    public String name;
058
059    public String ldapUrls;
060
061    public String bindDn;
062
063    @XNode("connectionTimeout")
064    public int connectionTimeout = 10000; // timeout after 10 seconds
065
066    @XNode("poolingEnabled")
067    public boolean poolingEnabled = true;
068
069    @XNode("verifyServerCert")
070    public boolean verifyServerCert = true;
071
072    /**
073     * @since 5.7
074     */
075    @XNode("retries")
076    public int retries = 5;
077
078    protected LinkedHashSet<LdapEntry> ldapEntries;
079
080    protected boolean isDynamicServerList = false;
081
082    protected boolean useSsl = false;
083
084    protected final DNSServiceResolver srvResolver = DNSServiceResolverImpl.getInstance();
085
086    public boolean isDynamicServerList() {
087        return isDynamicServerList;
088    }
089
090    public String getName() {
091        return name;
092    }
093
094    public String bindPassword = "";
095
096    @XNode("bindDn")
097    public void setBindDn(String bindDn) {
098        if (null != bindDn && bindDn.trim().equals("")) {
099            // empty bindDn means anonymous authentication
100            this.bindDn = null;
101        } else {
102            this.bindDn = bindDn;
103        }
104    }
105
106    public String getBindDn() {
107        return bindDn;
108    }
109
110    @XNode("bindPassword")
111    public void setBindPassword(String bindPassword) {
112        if (bindPassword == null) {
113            // no password means empty pasword
114            this.bindPassword = "";
115        } else {
116            this.bindPassword = bindPassword;
117        }
118    }
119
120    public String getBindPassword() {
121        return bindPassword;
122    }
123
124    public String getLdapUrls() {
125        if (ldapUrls != null) {
126            return ldapUrls;
127        }
128
129        // Leverage JNDI support for clustered servers by concatenating
130        // all the provided URLs for fail-over
131        StringBuilder calculatedLdapUrls = new StringBuilder();
132        for (LdapEntry entry : ldapEntries) {
133            calculatedLdapUrls.append(entry);
134            calculatedLdapUrls.append(' ');
135        }
136
137        /*
138         * If the configuration does not contain any domain entries then cache the urls, domain entries should always be
139         * re-queried however as the LDAP server list should change dynamically
140         */
141        if (!isDynamicServerList) {
142            return ldapUrls = calculatedLdapUrls.toString().trim();
143        }
144        return calculatedLdapUrls.toString().trim();
145    }
146
147    @XNodeList(value = "ldapUrl", componentType = LDAPUrlDescriptor.class, type = LDAPUrlDescriptor[].class)
148    public void setLdapUrls(LDAPUrlDescriptor[] ldapUrls) throws DirectoryException {
149        if (ldapUrls == null) {
150            throw new DirectoryException("At least one <ldapUrl/> server declaration is required");
151        }
152        ldapEntries = new LinkedHashSet<LdapEntry>();
153
154        Set<LDAPUrlDescriptor> processed = new HashSet<LDAPUrlDescriptor>();
155
156        List<String> urls = new ArrayList<String>(ldapUrls.length);
157        for (LDAPUrlDescriptor url : ldapUrls) {
158            LdapURL ldapUrl;
159            try {
160                /*
161                 * Empty string translates to ldap://localhost:389 through JNDI
162                 */
163                if (StringUtils.isEmpty(url.getValue())) {
164                    urls.add(url.getValue());
165                    ldapEntries.add(new LdapEntryDescriptor(url));
166                    continue;
167                }
168
169                /*
170                 * Parse the URI to make sure it is valid
171                 */
172                ldapUrl = new LdapURL(url.getValue());
173                if (!processed.add(url)) {
174                    continue;
175                }
176            } catch (NamingException e) {
177                throw new DirectoryException(e);
178            }
179
180            useSsl = useSsl || ldapUrl.useSsl();
181
182            /*
183             * RFC-2255 - The "ldap" prefix indicates an entry or entries residing in the LDAP server running on the
184             * given hostname at the given port number. The default LDAP port is TCP port 389. If no hostport is given,
185             * the client must have some apriori knowledge of an appropriate LDAP server to contact.
186             */
187            if (ldapUrl.getHost() == null) {
188                /*
189                 * RFC-2782 - Check to see if an LDAP SRV record is defined in the DNS server
190                 */
191                String domain = convertDNtoFQDN(ldapUrl.getDN());
192                if (domain != null) {
193                    /*
194                     * Dynamic URL - retrieve from SRV record
195                     */
196                    List<String> discoveredUrls;
197                    try {
198                        discoveredUrls = discoverLdapServers(domain, ldapUrl.useSsl(), url.getSrvPrefix());
199                    } catch (NamingException e) {
200                        throw new DirectoryException(String.format("SRV record DNS lookup failed for %s.%s: %s",
201                                url.getSrvPrefix(), domain, e.getMessage()), e);
202                    }
203
204                    /*
205                     * Discovered URLs could be empty, lets check at the end though
206                     */
207                    urls.addAll(discoveredUrls);
208
209                    /*
210                     * Store entries in an ordered set and remember that we were dynamic
211                     */
212                    ldapEntries.add(new LdapEntryDomain(url, domain, ldapUrl.useSsl()));
213                    isDynamicServerList = true;
214                } else {
215                    throw new DirectoryException("Invalid LDAP SRV reference, this should be of the form"
216                            + " ldap:///dc=example,dc=org");
217                }
218            } else {
219                /*
220                 * Static URL - store the value
221                 */
222                urls.add(url.getValue());
223
224                /*
225                 * Store entries in an ordered set
226                 */
227                ldapEntries.add(new LdapEntryDescriptor(url));
228            }
229        }
230
231        /*
232         * Oops no valid URLs to connect to :(
233         */
234        if (urls.isEmpty()) {
235            throw new DirectoryException("No valid server urls returned from DNS query");
236        }
237    }
238
239    /**
240     * Whether this server descriptor defines a secure ldap connection
241     */
242    public boolean useSsl() {
243        return useSsl;
244    }
245
246    /**
247     * Retrieve server URLs from DNS SRV record
248     *
249     * @param domain The domain to query
250     * @param useSsl Whether the connection to this domain should be secure
251     * @return List of servers or empty list
252     * @throws NamingException if DNS lookup fails
253     */
254    protected List<String> discoverLdapServers(String domain, boolean useSsl, String srvPrefix) throws NamingException {
255        List<String> result = new ArrayList<String>();
256        List<DNSServiceEntry> servers = getSRVResolver().resolveLDAPDomainServers(domain, srvPrefix);
257
258        for (DNSServiceEntry serviceEntry : servers) {
259            /*
260             * Rebuild the URL
261             */
262            StringBuilder realUrl = (useSsl) ? new StringBuilder(LDAPS_SCHEME + "://") : new StringBuilder(LDAP_SCHEME
263                    + "://");
264            realUrl.append(serviceEntry);
265            result.add(realUrl.toString());
266        }
267        return result;
268    }
269
270    /**
271     * Convert domain from the ldap form dc=nuxeo,dc=org to the DNS domain name form nuxeo.org
272     *
273     * @param dn base DN of the domain
274     * @return the FQDN or null is DN is not matching the expected structure
275     * @throws DirectoryException is the DN is invalid
276     */
277    protected String convertDNtoFQDN(String dn) throws DirectoryException {
278        try {
279            LdapDN ldapDN = new LdapDN(dn);
280            Enumeration<String> components = ldapDN.getAll();
281            List<String> domainComponents = new ArrayList<String>();
282            while (components.hasMoreElements()) {
283                String component = components.nextElement();
284                if (component.startsWith("dc=")) {
285                    domainComponents.add(component.substring(3));
286                } else {
287                    break;
288                }
289            }
290            Collections.reverse(domainComponents);
291            return StringUtils.join(domainComponents, ".");
292        } catch (InvalidNameException e) {
293            throw new DirectoryException(e);
294        }
295    }
296
297    public boolean isPoolingEnabled() {
298        return poolingEnabled;
299    }
300
301    public boolean isVerifyServerCert() {
302        return verifyServerCert;
303    }
304
305    public int getConnectionTimeout() {
306        return connectionTimeout;
307    }
308
309    public void setConnectionTimeout(int connectionTimeout) {
310        this.connectionTimeout = connectionTimeout;
311    }
312
313    protected DNSServiceResolver getSRVResolver() {
314        return srvResolver;
315    }
316
317    /**
318     * Common internal interface for Ldap entries
319     *
320     * @author Bob Browning
321     */
322    protected interface LdapEntry {
323        String getUrl() throws NamingException;
324    }
325
326    /**
327     * Server URL implementation of {@link LdapEntry}
328     *
329     * @author Bob Browning
330     */
331    protected class LdapEntryDescriptor implements LdapEntry {
332
333        protected LDAPUrlDescriptor url;
334
335        public LdapEntryDescriptor(LDAPUrlDescriptor descriptor) {
336            url = descriptor;
337        }
338
339        @Override
340        public String toString() {
341            try {
342                return getUrl();
343            } catch (NamingException e) {
344                log.error(e, e);
345                return "[DNS lookup failed]";
346            }
347        }
348
349        @Override
350        public boolean equals(Object obj) {
351            if (obj instanceof LdapEntryDescriptor) {
352                return url.equals(obj);
353            }
354            return false;
355        }
356
357        @Override
358        public int hashCode() {
359            return url.hashCode();
360        }
361
362        public String getUrl() throws NamingException {
363            return url.getValue();
364        }
365
366    }
367
368    /**
369     * Domain implementation of {@link LdapEntry} using DNS SRV record
370     *
371     * @author Bob Browning
372     */
373    protected final class LdapEntryDomain extends LdapEntryDescriptor {
374
375        protected final String domain;
376
377        protected final boolean useSsl;
378
379        public LdapEntryDomain(LDAPUrlDescriptor descriptor, final String domain, boolean useSsl) {
380            super(descriptor);
381            this.domain = domain;
382            this.useSsl = useSsl;
383        }
384
385        @Override
386        public String getUrl() throws NamingException {
387            List<DNSServiceEntry> servers = getSRVResolver().resolveLDAPDomainServers(domain, url.getSrvPrefix());
388
389            StringBuilder result = new StringBuilder();
390            for (DNSServiceEntry serviceEntry : servers) {
391                /*
392                 * Rebuild the URL
393                 */
394                result.append(useSsl ? LDAPS_SCHEME + "://" : LDAP_SCHEME + "://");
395                result.append(serviceEntry);
396                result.append(' ');
397            }
398            return result.toString().trim();
399        }
400
401        private LDAPServerDescriptor getOuterType() {
402            return LDAPServerDescriptor.this;
403        }
404
405        @Override
406        public int hashCode() {
407            final int prime = 31;
408            int result = super.hashCode();
409            result = prime * result + getOuterType().hashCode();
410            result = prime * result + ((domain == null) ? 0 : domain.hashCode());
411            result = prime * result + (useSsl ? 1231 : 1237);
412            return result;
413        }
414
415        @Override
416        public boolean equals(Object obj) {
417            if (this == obj) {
418                return true;
419            }
420            if (!super.equals(obj)) {
421                return false;
422            }
423            if (getClass() != obj.getClass()) {
424                return false;
425            }
426            LdapEntryDomain other = (LdapEntryDomain) obj;
427            if (!getOuterType().equals(other.getOuterType())) {
428                return false;
429            }
430            if (domain == null) {
431                if (other.domain != null) {
432                    return false;
433                }
434            } else if (!domain.equals(other.domain)) {
435                return false;
436            }
437            if (useSsl != other.useSsl) {
438                return false;
439            }
440            return true;
441        }
442    }
443
444    /**
445     * @since 5.7
446     */
447    public int getRetries() {
448        return retries;
449    }
450
451}