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        @Override
363        public String getUrl() throws NamingException {
364            return url.getValue();
365        }
366
367    }
368
369    /**
370     * Domain implementation of {@link LdapEntry} using DNS SRV record
371     *
372     * @author Bob Browning
373     */
374    protected final class LdapEntryDomain extends LdapEntryDescriptor {
375
376        protected final String domain;
377
378        protected final boolean useSsl;
379
380        public LdapEntryDomain(LDAPUrlDescriptor descriptor, final String domain, boolean useSsl) {
381            super(descriptor);
382            this.domain = domain;
383            this.useSsl = useSsl;
384        }
385
386        @Override
387        public String getUrl() throws NamingException {
388            List<DNSServiceEntry> servers = getSRVResolver().resolveLDAPDomainServers(domain, url.getSrvPrefix());
389
390            StringBuilder result = new StringBuilder();
391            for (DNSServiceEntry serviceEntry : servers) {
392                /*
393                 * Rebuild the URL
394                 */
395                result.append(useSsl ? LDAPS_SCHEME + "://" : LDAP_SCHEME + "://");
396                result.append(serviceEntry);
397                result.append(' ');
398            }
399            return result.toString().trim();
400        }
401
402        private LDAPServerDescriptor getOuterType() {
403            return LDAPServerDescriptor.this;
404        }
405
406        @Override
407        public int hashCode() {
408            final int prime = 31;
409            int result = super.hashCode();
410            result = prime * result + getOuterType().hashCode();
411            result = prime * result + ((domain == null) ? 0 : domain.hashCode());
412            result = prime * result + (useSsl ? 1231 : 1237);
413            return result;
414        }
415
416        @Override
417        public boolean equals(Object obj) {
418            if (this == obj) {
419                return true;
420            }
421            if (!super.equals(obj)) {
422                return false;
423            }
424            if (getClass() != obj.getClass()) {
425                return false;
426            }
427            LdapEntryDomain other = (LdapEntryDomain) obj;
428            if (!getOuterType().equals(other.getOuterType())) {
429                return false;
430            }
431            if (domain == null) {
432                if (other.domain != null) {
433                    return false;
434                }
435            } else if (!domain.equals(other.domain)) {
436                return false;
437            }
438            if (useSsl != other.useSsl) {
439                return false;
440            }
441            return true;
442        }
443    }
444
445    /**
446     * @since 5.7
447     */
448    public int getRetries() {
449        return retries;
450    }
451
452}