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) {
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     */
282    protected String convertDNtoFQDN(String dn) {
283        try {
284            LdapDN ldapDN = new LdapDN(dn);
285            Enumeration<String> components = ldapDN.getAll();
286            List<String> domainComponents = new ArrayList<>();
287            while (components.hasMoreElements()) {
288                String component = components.nextElement();
289                if (component.startsWith("dc=")) {
290                    domainComponents.add(component.substring(3));
291                } else {
292                    break;
293                }
294            }
295            Collections.reverse(domainComponents);
296            return StringUtils.join(domainComponents, ".");
297        } catch (InvalidNameException e) {
298            throw new DirectoryException(e);
299        }
300    }
301
302    public boolean isPoolingEnabled() {
303        return poolingEnabled;
304    }
305
306    public boolean isVerifyServerCert() {
307        return verifyServerCert;
308    }
309
310    public int getConnectionTimeout() {
311        return connectionTimeout;
312    }
313
314    /**
315     * @since 10.2
316     */
317    public int getPoolingTimeout() {
318        return poolingTimeout;
319    }
320
321    public void setConnectionTimeout(int connectionTimeout) {
322        this.connectionTimeout = connectionTimeout;
323    }
324
325    protected DNSServiceResolver getSRVResolver() {
326        return srvResolver;
327    }
328
329    /**
330     * Common internal interface for Ldap entries
331     *
332     * @author Bob Browning
333     */
334    protected interface LdapEntry {
335        String getUrl() throws NamingException;
336    }
337
338    /**
339     * Server URL implementation of {@link LdapEntry}
340     *
341     * @author Bob Browning
342     */
343    protected class LdapEntryDescriptor implements LdapEntry {
344
345        protected LDAPUrlDescriptor url;
346
347        public LdapEntryDescriptor(LDAPUrlDescriptor descriptor) {
348            url = descriptor;
349        }
350
351        @Override
352        public String toString() {
353            try {
354                return getUrl();
355            } catch (NamingException e) {
356                log.error(e, e);
357                return "[DNS lookup failed]";
358            }
359        }
360
361        @Override
362        public boolean equals(Object obj) {
363            if (obj instanceof LdapEntryDescriptor) {
364                return url.equals(((LdapEntryDescriptor) obj).url);
365            }
366            return false;
367        }
368
369        @Override
370        public int hashCode() {
371            return url.hashCode();
372        }
373
374        @Override
375        public String getUrl() throws NamingException {
376            return url.getValue();
377        }
378
379    }
380
381    /**
382     * Domain implementation of {@link LdapEntry} using DNS SRV record
383     *
384     * @author Bob Browning
385     */
386    protected final class LdapEntryDomain extends LdapEntryDescriptor {
387
388        protected final String domain;
389
390        protected final boolean useSsl;
391
392        public LdapEntryDomain(LDAPUrlDescriptor descriptor, final String domain, boolean useSsl) {
393            super(descriptor);
394            this.domain = domain;
395            this.useSsl = useSsl;
396        }
397
398        @Override
399        public String getUrl() throws NamingException {
400            List<DNSServiceEntry> servers = getSRVResolver().resolveLDAPDomainServers(domain, url.getSrvPrefix());
401
402            StringBuilder result = new StringBuilder();
403            for (DNSServiceEntry serviceEntry : servers) {
404                /*
405                 * Rebuild the URL
406                 */
407                result.append(useSsl ? LDAPS_SCHEME + "://" : LDAP_SCHEME + "://");
408                result.append(serviceEntry);
409                result.append(' ');
410            }
411            return result.toString().trim();
412        }
413
414        private LDAPServerDescriptor getOuterType() {
415            return LDAPServerDescriptor.this;
416        }
417
418        @Override
419        public int hashCode() {
420            final int prime = 31;
421            int result = super.hashCode();
422            result = prime * result + getOuterType().hashCode();
423            result = prime * result + ((domain == null) ? 0 : domain.hashCode());
424            result = prime * result + (useSsl ? 1231 : 1237);
425            return result;
426        }
427
428        @Override
429        public boolean equals(Object obj) {
430            if (this == obj) {
431                return true;
432            }
433            if (!super.equals(obj)) {
434                return false;
435            }
436            if (getClass() != obj.getClass()) {
437                return false;
438            }
439            LdapEntryDomain other = (LdapEntryDomain) obj;
440            if (!getOuterType().equals(other.getOuterType())) {
441                return false;
442            }
443            if (domain == null) {
444                if (other.domain != null) {
445                    return false;
446                }
447            } else if (!domain.equals(other.domain)) {
448                return false;
449            }
450            if (useSsl != other.useSsl) {
451                return false;
452            }
453            return true;
454        }
455    }
456
457    /**
458     * @since 5.7
459     */
460    public int getRetries() {
461        return retries;
462    }
463
464}