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