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