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