xref: /OpenGrok/plugins/src/main/java/opengrok/auth/plugin/ldap/LdapFacade.java (revision 05aaab8c8c0f8b98f21471783e0303da92bd6090)
1 /*
2  * CDDL HEADER START
3  *
4  * The contents of this file are subject to the terms of the
5  * Common Development and Distribution License (the "License").
6  * You may not use this file except in compliance with the License.
7  *
8  * See LICENSE.txt included in this distribution for the specific
9  * language governing permissions and limitations under the License.
10  *
11  * When distributing Covered Code, include this CDDL HEADER in each
12  * file and include the License file at LICENSE.txt.
13  * If applicable, add the following below this CDDL HEADER, with the
14  * fields enclosed by brackets "[]" replaced with your own identifying
15  * information: Portions Copyright [yyyy] [name of copyright owner]
16  *
17  * CDDL HEADER END
18  */
19 
20 /*
21  * Copyright (c) 2016, 2020, Oracle and/or its affiliates. All rights reserved.
22  */
23 package opengrok.auth.plugin.ldap;
24 
25 import java.time.Duration;
26 import java.time.Instant;
27 import java.util.ArrayList;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31 import java.util.Set;
32 import java.util.TreeSet;
33 import java.util.logging.Level;
34 import java.util.logging.Logger;
35 import javax.naming.CommunicationException;
36 import javax.naming.NameNotFoundException;
37 import javax.naming.NamingEnumeration;
38 import javax.naming.NamingException;
39 import javax.naming.SizeLimitExceededException;
40 import javax.naming.TimeLimitExceededException;
41 import javax.naming.directory.Attribute;
42 import javax.naming.directory.Attributes;
43 import javax.naming.directory.SearchControls;
44 import javax.naming.directory.SearchResult;
45 
46 import io.micrometer.core.instrument.Timer;
47 import opengrok.auth.plugin.configuration.Configuration;
48 import opengrok.auth.plugin.util.WebHook;
49 import opengrok.auth.plugin.util.WebHooks;
50 import org.opengrok.indexer.Metrics;
51 
52 public class LdapFacade extends AbstractLdapProvider {
53 
54     private static final Logger LOGGER = Logger.getLogger(LdapFacade.class.getName());
55 
56     /**
57      * Default LDAP filter.
58      */
59     private static final String LDAP_FILTER = "objectclass=*";
60 
61     /**
62      * Default timeout for retrieving the results.
63      */
64     private static final int LDAP_SEARCH_TIMEOUT = 5000; // ms
65 
66     /**
67      * Default limit of result traversal.
68      *
69      * @see
70      * <a href="https://docs.oracle.com/javase/7/docs/api/javax/naming/directory/SearchControls.html#setCountLimit%28long%29">SearchControls</a>
71      *
72      * basically it does not mean that the server must send at most this number
73      * of results, but that the program should not iterate more than this number
74      * over the results.
75      */
76     private static final int LDAP_COUNT_LIMIT = 100;
77 
78     /**
79      * When there is no active server in the pool, the facade waits this time
80      * interval (since the last failure) until it tries the servers again.
81      *
82      * This should avoid heavy load to the LDAP servers when they are all
83      * broken/not responding/down - pool waiting.
84      *
85      * Also each server uses this same interval since its last failure - per
86      * server waiting.
87      */
88     private int interval = 10 * 1000; // ms
89 
90     /**
91      * LDAP search base.
92      */
93     private String searchBase;
94 
95     /**
96      * Server pool.
97      */
98     private List<LdapServer> servers = new ArrayList<>();
99 
100     /**
101      * server webHooks.
102      */
103     private WebHooks webHooks;
104 
105     private SearchControls controls;
106     private int actualServer = -1;
107     private long errorTimestamp = 0;
108     private boolean reported = false;
109 
110     private final Timer ldapLookupTimer = Timer.builder("ldap.latency").
111             description("LDAP lookup latency").
112             register(Metrics.getRegistry());
113 
114     /**
115      * Interface for converting LDAP results into user defined types.
116      *
117      * @param <T> the type of the result
118      */
119     private interface AttributeMapper<T> {
120 
mapFromAttributes(Attributes attr)121         T mapFromAttributes(Attributes attr) throws NamingException;
122     }
123 
124     /**
125      * Transforms the attributes to the set of strings used for authorization.
126      *
127      * Currently this behaves like it get all records stored in
128      */
129     private static class ContentAttributeMapper implements AttributeMapper<Map<String, Set<String>>> {
130 
131         private final String[] values;
132 
133         /**
134          * Create a new mapper which retrieves the given values in the resulting
135          * set.
136          *
137          * @param values include these values in the result
138          */
ContentAttributeMapper(String[] values)139         ContentAttributeMapper(String[] values) {
140             this.values = values;
141         }
142 
143         @Override
mapFromAttributes(Attributes attrs)144         public Map<String, Set<String>> mapFromAttributes(Attributes attrs) throws NamingException {
145             Map<String, Set<String>> map = new HashMap<>();
146 
147             if (values == null) {
148                 for (NamingEnumeration<? extends Attribute> attrEnum = attrs.getAll(); attrEnum.hasMore();) {
149                     Attribute attr = attrEnum.next();
150 
151                     addAttrToMap(map, attr);
152                 }
153             } else {
154                 for (String value : values) {
155                     Attribute attr = attrs.get(value);
156 
157                     if (attr == null) {
158                         continue;
159                     }
160 
161                     addAttrToMap(map, attr);
162                 }
163             }
164 
165             return map;
166         }
167 
addAttrToMap(Map<String, Set<String>> map, Attribute attr)168         private void addAttrToMap(Map<String, Set<String>> map, Attribute attr) throws NamingException {
169             if (!map.containsKey(attr.getID())) {
170                 map.put(attr.getID(), new TreeSet<>());
171             }
172 
173             final Set<String> valueSet = map.get(attr.getID());
174 
175             for (NamingEnumeration<?> values = attr.getAll(); values.hasMore(); ) {
176                 valueSet.add((String) values.next());
177             }
178         }
179     }
180 
LdapFacade(Configuration cfg)181     public LdapFacade(Configuration cfg) {
182         setServers(cfg.getServers(), cfg.getConnectTimeout(), cfg.getReadTimeout());
183         setInterval(cfg.getInterval());
184         setSearchBase(cfg.getSearchBase());
185         setWebHooks(cfg.getWebHooks());
186 
187         // Anti-pattern: do some non trivial stuff in the constructor.
188         prepareSearchControls(cfg.getSearchTimeout(), cfg.getCountLimit());
189         prepareServers();
190     }
191 
setWebHooks(WebHooks webHooks)192     private void setWebHooks(WebHooks webHooks) {
193         this.webHooks = webHooks;
194     }
195 
196     /**
197      * Go through all servers in the pool and record the first working.
198      */
prepareServers()199     void prepareServers() {
200         LOGGER.log(Level.FINER, "checking servers for {0}", this);
201         for (int i = 0; i < servers.size(); i++) {
202             LdapServer server = servers.get(i);
203             if (server.isWorking() && actualServer == -1) {
204                 actualServer = i;
205             }
206         }
207 
208         // Close the connections to the inactive servers.
209         LOGGER.log(Level.FINER, "closing unused servers");
210         for (int i = 0; i < servers.size(); i++) {
211             if (i != actualServer) {
212                 servers.get(i).close();
213             }
214         }
215 
216         if (LOGGER.isLoggable(Level.FINER)) {
217             LOGGER.log(Level.FINER, String.format("server check done (current server: %s)",
218                     actualServer != -1 ? servers.get(actualServer) : "N/A"));
219         }
220     }
221 
222     /**
223      * Closes all available servers.
224      */
225     @Override
close()226     public void close() {
227         for (LdapServer server : servers) {
228             server.close();
229         }
230     }
231 
getServers()232     public List<LdapServer> getServers() {
233         return servers;
234     }
235 
setServers(List<LdapServer> servers, int connectTimeout, int readTimeout)236     public LdapFacade setServers(List<LdapServer> servers, int connectTimeout, int readTimeout) {
237         this.servers = servers;
238         // Inherit timeout values from server pool configuration.
239         for (LdapServer server : servers) {
240             if (server.getConnectTimeout() == 0 && connectTimeout != 0) {
241                 server.setConnectTimeout(connectTimeout);
242             }
243             if (server.getReadTimeout() == 0 && readTimeout != 0) {
244                 server.setReadTimeout(readTimeout);
245             }
246         }
247         return this;
248     }
249 
getInterval()250     public int getInterval() {
251         return interval;
252     }
253 
setInterval(int interval)254     public void setInterval(int interval) {
255         this.interval = interval;
256         for (LdapServer server : servers) {
257             server.setInterval(interval);
258         }
259     }
260 
getSearchBase()261     public String getSearchBase() {
262         return searchBase;
263     }
264 
setSearchBase(String base)265     public void setSearchBase(String base) {
266         this.searchBase = base;
267     }
268 
269     @Override
isConfigured()270     public boolean isConfigured() {
271         return servers != null && !servers.isEmpty() && searchBase != null && actualServer != -1;
272     }
273 
274     /**
275      * Get LDAP attributes.
276      *
277      * @param dn LDAP DN attribute. If @{code null} then {@code searchBase} will be used.
278      * @param filter LDAP filter to use. If @{code null} then @{link LDAP_FILTER} will be used.
279      * @param values match these LDAP values
280      *
281      * @return set of strings describing the user's attributes
282      */
283     @Override
lookupLdapContent(String dn, String filter, String[] values)284     public LdapSearchResult<Map<String, Set<String>>> lookupLdapContent(String dn, String filter, String[] values) throws LdapException {
285 
286         return lookup(
287                 dn != null ? dn : getSearchBase(),
288                 filter == null ? LDAP_FILTER : filter,
289                 values,
290                 new ContentAttributeMapper(values));
291     }
292 
prepareSearchControls(int ldapTimeout, int ldapCountLimit)293     private SearchControls prepareSearchControls(int ldapTimeout, int ldapCountLimit) {
294         controls = new SearchControls();
295         controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
296         controls.setTimeLimit(ldapTimeout > 0 ? ldapTimeout : LDAP_SEARCH_TIMEOUT);
297         controls.setCountLimit(ldapCountLimit > 0 ? ldapCountLimit : LDAP_COUNT_LIMIT);
298 
299         return controls;
300     }
301 
getSearchControls()302     public SearchControls getSearchControls() {
303         return controls;
304     }
305 
306     /**
307      * Lookups the LDAP server for content.
308      *
309      * @param <T> return type
310      * @param dn search base for the query
311      * @param filter LDAP filter for the query
312      * @param attributes returning LDAP attributes
313      * @param mapper mapper class implementing @code{AttributeMapper} closed
314      *
315      * @return results transformed with mapper
316      */
lookup(String dn, String filter, String[] attributes, AttributeMapper<T> mapper)317     private <T> LdapSearchResult<T> lookup(String dn, String filter, String[] attributes, AttributeMapper<T> mapper) throws LdapException {
318         Instant start = Instant.now();
319         LdapSearchResult<T> res = lookup(dn, filter, attributes, mapper, 0);
320         ldapLookupTimer.record(Duration.between(start, Instant.now()));
321         return res;
322     }
323 
324     // available for testing
getSearchDescription(String dn, String filter, String[] attributes)325     static String getSearchDescription(String dn, String filter, String[] attributes) {
326         StringBuilder builder = new StringBuilder();
327         builder.append("DN: ");
328         builder.append(dn);
329         builder.append(", filter: ");
330         builder.append(filter);
331         if (attributes != null) {
332             builder.append(", attributes: ");
333             builder.append(String.join(",", attributes));
334         }
335         return builder.toString();
336     }
337 
338     /**
339      * Lookups the LDAP server for content.
340      *
341      * @param <T> return type
342      * @param dn search base for the query
343      * @param filter LDAP filter for the query
344      * @param attributes returning LDAP attributes
345      * @param mapper mapper class implementing @code{AttributeMapper} closed
346      * @param fail current count of failures
347      *
348      * @return results transformed with mapper or {@code null} on failure
349      * @throws LdapException LDAP exception
350      */
lookup(String dn, String filter, String[] attributes, AttributeMapper<T> mapper, int fail)351     private <T> LdapSearchResult<T> lookup(String dn, String filter, String[] attributes, AttributeMapper<T> mapper, int fail) throws LdapException {
352 
353         if (errorTimestamp > 0 && errorTimestamp + interval > System.currentTimeMillis()) {
354             if (!reported) {
355                 reported = true;
356                 LOGGER.log(Level.SEVERE, "LDAP server pool is still broken");
357             }
358             throw new LdapException("LDAP server pool is still broken");
359         }
360 
361         if (fail > servers.size() - 1) {
362             // did the whole rotation
363             LOGGER.log(Level.SEVERE, "Tried all LDAP servers in a pool but no server works");
364             errorTimestamp = System.currentTimeMillis();
365             reported = false;
366             WebHook hook;
367             if ((hook = webHooks.getFail()) != null) {
368                 hook.post();
369             }
370             throw new LdapException("Tried all LDAP servers in a pool but no server works");
371         }
372 
373         if (!isConfigured()) {
374             LOGGER.log(Level.SEVERE, "LDAP is not configured");
375             throw new LdapException("LDAP is not configured");
376         }
377 
378         NamingEnumeration<SearchResult> namingEnum = null;
379         LdapServer server = null;
380         try {
381             server = servers.get(actualServer);
382             controls.setReturningAttributes(attributes);
383             for (namingEnum = server.search(dn, filter, controls); namingEnum.hasMore();) {
384                 SearchResult sr = namingEnum.next();
385                 reported = false;
386                 if (errorTimestamp > 0) {
387                     errorTimestamp = 0;
388                     WebHook hook;
389                     if ((hook = webHooks.getRecover()) != null) {
390                         hook.post();
391                     }
392                 }
393 
394                 return new LdapSearchResult<>(sr.getNameInNamespace(), processResult(sr, mapper));
395             }
396         } catch (NameNotFoundException ex) {
397             LOGGER.log(Level.WARNING, String.format("The LDAP name for search '%s' was not found on server %s",
398                     getSearchDescription(dn, filter, attributes), server), ex);
399             throw new LdapException("The LDAP name was not found.", ex);
400         } catch (SizeLimitExceededException ex) {
401             LOGGER.log(Level.SEVERE, String.format("The maximum size of the LDAP result has exceeded "
402                     + "on server %s", server), ex);
403             closeActualServer();
404             actualServer = getNextServer();
405             return lookup(dn, filter, attributes, mapper, fail + 1);
406         } catch (TimeLimitExceededException ex) {
407             LOGGER.log(Level.SEVERE, String.format("Time limit for LDAP operation has exceeded on server %s",
408                     server), ex);
409             closeActualServer();
410             actualServer = getNextServer();
411             return lookup(dn, filter, attributes, mapper, fail + 1);
412         } catch (CommunicationException ex) {
413             LOGGER.log(Level.WARNING, String.format("Communication error received on server %s, " +
414                     "reconnecting to next server.", server), ex);
415             closeActualServer();
416             actualServer = getNextServer();
417             return lookup(dn, filter, attributes, mapper, fail + 1);
418         } catch (NamingException ex) {
419             LOGGER.log(Level.SEVERE, String.format("An arbitrary LDAP error occurred on server %s " +
420                     "when searching for '%s'", server, getSearchDescription(dn, filter, attributes)), ex);
421             closeActualServer();
422             actualServer = getNextServer();
423             return lookup(dn, filter, attributes, mapper, fail + 1);
424         } finally {
425             if (namingEnum != null) {
426                 try {
427                     namingEnum.close();
428                 } catch (NamingException e) {
429                     LOGGER.log(Level.WARNING,
430                             "failed to close search result enumeration");
431                 }
432             }
433         }
434 
435         return null;
436     }
437 
closeActualServer()438     private void closeActualServer() {
439         servers.get(actualServer).close();
440     }
441 
442     /**
443      * Server take over algorithm behavior.
444      *
445      * @return the index of the next server to be used
446      */
getNextServer()447     private int getNextServer() {
448         return (actualServer + 1) % servers.size();
449     }
450 
451     /**
452      * Process the incoming LDAP result.
453      *
454      * @param <T> type of the result
455      * @param result LDAP result
456      * @param mapper mapper to transform the result into the result type
457      * @return transformed result
458      *
459      * @throws NamingException naming exception
460      */
processResult(SearchResult result, AttributeMapper<T> mapper)461     private <T> T processResult(SearchResult result, AttributeMapper<T> mapper) throws NamingException {
462         Attributes attrs = result.getAttributes();
463         if (attrs != null) {
464             return mapper.mapFromAttributes(attrs);
465         }
466 
467         return null;
468     }
469 
470     @Override
toString()471     public String toString() {
472         return "{server=" + (actualServer != -1 ? servers.get(actualServer) : "no active server") +
473                 ", searchBase=" + getSearchBase() + "}";
474     }
475 }
476