001package com.nimbusds.infinispan.persistence.ldap.backend; 002 003 004import java.util.LinkedList; 005import java.util.List; 006import java.util.Set; 007import java.util.concurrent.atomic.AtomicInteger; 008 009import com.codahale.metrics.Timer; 010import com.nimbusds.common.appendable.Appendable; 011import com.nimbusds.common.ldap.LDAPConnectionPoolFactory; 012import com.nimbusds.common.ldap.LDAPConnectionPoolMetrics; 013import com.nimbusds.common.ldap.LDAPHealthCheck; 014import com.nimbusds.common.monitor.MonitorRegistries; 015import com.nimbusds.infinispan.persistence.ldap.Loggers; 016import com.nimbusds.infinispan.persistence.ldap.LDAPStoreConfiguration; 017import com.unboundid.asn1.ASN1OctetString; 018import com.unboundid.ldap.sdk.*; 019import com.unboundid.ldap.sdk.controls.SimplePagedResultsControl; 020import net.jcip.annotations.ThreadSafe; 021import org.infinispan.persistence.spi.PersistenceException; 022 023 024/** 025 * LDAP connector to the backend directory. Provides retrieval, addition / 026 * modify and deletion operations. 027 */ 028@ThreadSafe 029public class LDAPConnector { 030 031 032 /** 033 * Filter that matches any LDAP entry. 034 */ 035 public static final Filter MATCH_ANY_FILTER = Filter.createPresenceFilter("objectClass"); 036 037 038 /** 039 * The LDAP configuration. 040 */ 041 private final LDAPStoreConfiguration config; 042 043 044 /** 045 * The LDAP connection pool. 046 */ 047 private final LDAPConnectionPool ldapConnPool; 048 049 050 /** 051 * The LDAP request factory. 052 */ 053 private final LDAPModifyRequestFactory ldapModifyRequestFactory; 054 055 056 /** 057 * Indicates support for directory attributes that may include options 058 * (such as language tags). 059 */ 060 private final boolean supportAttributeOptions; 061 062 063 /** 064 * The LDAP operation timers. 065 */ 066 private final LDAPTimers ldapTimers; 067 068 069 /** 070 * The name of the associated Infinispan cache / map. 071 */ 072 private final String cacheName; 073 074 075 /** 076 * Creates a new LDAP connector. 077 * 078 * @param config The LDAP configuration. Must not be 079 * {@code null}. 080 * @param cacheName The name of the Infinispan cache 081 * associated with this LDAP connector. 082 * Must not be {@code null}. 083 * @param attributes The names of the supported directory 084 * attributes. Must not be empty or 085 * {@code null}. 086 * @param supportAttributeOptions {@code true} if any of the supported 087 * directory attributes may include 088 * options (such as language tags), else 089 * {@code false}. 090 */ 091 public LDAPConnector(final LDAPStoreConfiguration config, 092 final String cacheName, 093 final Set<String> attributes, 094 final boolean supportAttributeOptions) { 095 096 this.config = config; 097 this.cacheName = cacheName; 098 this.supportAttributeOptions = supportAttributeOptions; 099 100 LDAPConnectionPoolFactory factory = new LDAPConnectionPoolFactory( 101 config.ldapServer, 102 config.customTrustStore, 103 config.customKeyStore, 104 config.ldapUser); 105 106 try { 107 ldapConnPool = factory.createLDAPConnectionPool(); 108 } catch (Exception e) { 109 throw new PersistenceException("LDAP connection pool creation for " + cacheName + " cache failed: " + e.getMessage(), e); 110 } 111 112 ldapConnPool.setConnectionPoolName(cacheName); 113 114 checkBaseDN(); 115 116 // Setup factory for composing LDAP requests 117 ldapModifyRequestFactory = new LDAPModifyRequestFactory(attributes); 118 119 // Set up DropWizard Metrics 120 121 /// LDAP operation timers 122 ldapTimers = new LDAPTimers(cacheName + "."); 123 124 // Set up LDAP connection pool metrics and health check 125 final String prefix = cacheName + ".ldapStore"; 126 MonitorRegistries.register(new LDAPConnectionPoolMetrics(ldapConnPool, prefix)); 127 MonitorRegistries.register(prefix, new LDAPHealthCheck(ldapConnPool, config.ldapDirectory.baseDN, Loggers.LDAP_LOG)); 128 129 Loggers.MAIN_LOG.info("[IL0100] Created new LDAP store connector for " + cacheName + " cache"); 130 } 131 132 133 /** 134 * Checks if the configured directory base DN for the entries exists. 135 * If not a WARN message is logged. 136 */ 137 private void checkBaseDN() { 138 139 try { 140 if (ldapConnPool.getEntry(config.ldapDirectory.baseDN.toString()) == null) { 141 142 Loggers.MAIN_LOG.warn("[IL0101] The configured LDAP store base DN for {} cache doesn't exist: {}", 143 cacheName, 144 config.ldapDirectory.baseDN); 145 } 146 147 } catch (LDAPException e) { 148 149 Loggers.MAIN_LOG.warn("[IL0102] Couldn't verify the LDAP store base DN for {} cache: {}", 150 cacheName, 151 e.getMessage()); 152 } 153 } 154 155 156 /** 157 * Returns the underlying LDAP connection pool. 158 * 159 * @return The LDAP connection pool. 160 */ 161 public LDAPConnectionPool getPool() { 162 163 return ldapConnPool; 164 } 165 166 167 /** 168 * Retrieves the specified entry from the LDAP directory. 169 * 170 * @param dn The Distinguished Name (DN) of the entry to retrieve. Must 171 * not be {@code null}. 172 * 173 * @return The matching directory entry, {@code null} if not found (or 174 * insufficient privileges). 175 */ 176 public ReadOnlyEntry retrieveEntry(final DN dn) { 177 178 Timer.Context timerCtx = ldapTimers.getTimer.time(); 179 try { 180 return ldapConnPool.getEntry(dn.toString(), SearchRequest.ALL_USER_ATTRIBUTES); 181 } catch (LDAPException e) { 182 throw new PersistenceException("LDAP get of " + dn + " failed: " + e.getResultString(), e); 183 } finally { 184 timerCtx.stop(); 185 } 186 } 187 188 189 /** 190 * Retrieves all entries from the LDAP directory under the base DN. 191 * 192 * @param appendable Collects the matching directory entries. Must not 193 * be {@code null}. 194 */ 195 public void retrieveEntries(final Appendable<ReadOnlyEntry> appendable) { 196 197 SearchRequest searchRequest = new SearchRequest( 198 config.ldapDirectory.baseDN.toString(), 199 SearchScope.ONE, 200 MATCH_ANY_FILTER, 201 SearchRequest.ALL_USER_ATTRIBUTES); 202 203 doSearch(searchRequest, appendable); 204 } 205 206 207 /** 208 * Checks that the specified entry exists in the LDAP directory. 209 * 210 * @param dn The Distinguished Name (DN) of the entry to check. Must 211 * not be {@code null}. 212 * 213 * @return {@code true} if the entry exists, {@code false} if not 214 * found (or insufficient privileges). 215 */ 216 public boolean entryExists(final DN dn) { 217 218 Timer.Context timerCtx = ldapTimers.getTimer.time(); 219 try { 220 return ldapConnPool.getEntry(dn.toString(), SearchRequest.NO_ATTRIBUTES) != null; 221 } catch (LDAPException e) { 222 throw new PersistenceException("LDAP get of " + dn + " failed: " + e.getResultString(), e); 223 } finally { 224 timerCtx.stop(); 225 } 226 } 227 228 229 /** 230 * Adds the specified entry to the LDAP directory. 231 * 232 * @param entry The entry to add. Must not be {@code null}. 233 * 234 * @return {@code true} if the entry was added, {@code false} if there 235 * was a stored entry with that Distinguished Name (DN). 236 */ 237 public boolean addEntry(final ReadOnlyEntry entry) { 238 239 LDAPResult ldapResult; 240 241 Timer.Context timerCtx = ldapTimers.addTimer.time(); 242 243 try { 244 ldapResult = ldapConnPool.add(entry); 245 246 } catch (LDAPException e) { 247 248 if (e.getResultCode().equals(ResultCode.ENTRY_ALREADY_EXISTS)) { 249 return false; 250 } 251 252 throw new PersistenceException("LDAP add for " + entry.getDN() + " failed: " + e.getResultString(), e); 253 } finally { 254 timerCtx.stop(); 255 } 256 257 ResultCode resultCode = ldapResult.getResultCode(); 258 259 if (resultCode.equals(ResultCode.SUCCESS)) { 260 return true; 261 } 262 263 if (resultCode.equals(ResultCode.ENTRY_ALREADY_EXISTS)) { 264 return false; 265 } 266 267 // Other result code indicates exception 268 throw new PersistenceException("LDAP add for " + entry.getDN() + " failed: " + resultCode.getName()); 269 } 270 271 272 /** 273 * Replaces the specified entry in the LDAP directory. 274 * 275 * @param entry The entry to replace. Must not be {@code null}. 276 * 277 * @return {@code true} if the entry was found, {@code false} if 278 * there was no stored entry with that Distinguished Name (DN). 279 */ 280 public boolean replaceEntry(final ReadOnlyEntry entry) { 281 282 final ModifyRequest modifyRequest; 283 284 if (supportAttributeOptions) { 285 286 DN dn; 287 288 try { 289 dn = new DN(entry.getDN()); 290 } catch (LDAPException e) { 291 throw new PersistenceException(e.getMessage(), e); 292 } 293 // Fetch previous entry, otherwise attributes with 294 // options cannot be reliably updates 295 ReadOnlyEntry existingEntry = retrieveEntry(dn); 296 297 if (existingEntry == null) { 298 return false; // No such entry 299 } 300 301 // Create mod based on diff 302 modifyRequest = ldapModifyRequestFactory.composeModifyRequest(entry, existingEntry); 303 304 if (modifyRequest == null) { 305 // No diff detected, indicate entry was found though 306 return true; 307 } 308 309 } else { 310 311 // Create fully-specced mod 312 modifyRequest = ldapModifyRequestFactory.composeModifyRequest(entry); 313 } 314 315 LDAPResult ldapResult; 316 Timer.Context timerCtx = ldapTimers.modifyTimer.time(); 317 318 try { 319 ldapResult = ldapConnPool.modify(modifyRequest); 320 321 } catch (LDAPException e) { 322 323 if (e.getResultCode().equals(ResultCode.NO_SUCH_OBJECT)) { 324 return false; 325 } 326 327 throw new PersistenceException("LDAP modify for " + modifyRequest.getDN() + " failed: " + e.getResultString(), e); 328 } finally { 329 timerCtx.stop(); 330 } 331 332 ResultCode resultCode = ldapResult.getResultCode(); 333 334 if (resultCode.equals(ResultCode.SUCCESS)) { 335 return true; 336 } 337 338 if (resultCode.equals(ResultCode.NO_SUCH_OBJECT)) { 339 return false; 340 } 341 342 throw new PersistenceException("LDAP modify " + modifyRequest.getDN() + " failed: " + resultCode.getName()); 343 } 344 345 346 /** 347 * Deletes the specified entry from the LDAP directory. 348 * 349 * @param dn The Distinguished Name (DN) of the entry to delete. Must 350 * not be {@code null}. 351 * 352 * @return {@code true} if the matching directory entry was deleted, 353 * {@code false} if not found (or insufficient privileges). 354 */ 355 public boolean deleteEntry(final DN dn) { 356 357 DeleteRequest deleteRequest = new DeleteRequest(dn); 358 359 LDAPResult result; 360 Timer.Context timerCtx = ldapTimers.deleteTimer.time(); 361 362 try { 363 result = ldapConnPool.delete(deleteRequest); 364 365 } catch (LDAPException e) { 366 367 ResultCode resultCode = e.getResultCode(); 368 369 if (resultCode.equals(ResultCode.NO_SUCH_OBJECT)) 370 return false; 371 372 throw new PersistenceException("LDAP delete of " + dn + " failed: " + e.getResultString(), e); 373 } finally { 374 timerCtx.stop(); 375 } 376 377 ResultCode resultCode = result.getResultCode(); 378 379 if (resultCode.equals(ResultCode.SUCCESS)) { 380 return true; 381 } 382 383 if (resultCode.equals(ResultCode.NO_SUCH_OBJECT)) { 384 return false; 385 } 386 387 throw new PersistenceException("LDAP delete of " + dn + " failed: " + resultCode.getName()); 388 } 389 390 391 /** 392 * Checks if the specified LDAP exception is caused by the LDAP server 393 * being unavailable, disconnected or timing out. 394 * 395 * @param e The LDAP exception. Must not be {@code null}. 396 * 397 * @return {@code true} if the LDAP exception is caused by the LDAP 398 * server being unavailable, disconnected or timing out, else 399 * {@code false}. 400 */ 401 protected static boolean indicatesConnectionException(final LDAPException e) { 402 403 return indicatesConnectionException(e.getResultCode()); 404 } 405 406 407 /** 408 * Checks if the specified LDAP result code indicates the LDAP server 409 * is unavailable, disconnected or timing out. 410 * 411 * @param code The LDAP result code. Must not be {@code null}. 412 * 413 * @return {@code true} if the LDAP result code indicates the LDAP 414 * server is unavailable, disconnected or timing out, else 415 * {@code false}. 416 */ 417 protected static boolean indicatesConnectionException(final ResultCode code) { 418 419 return code.equals(ResultCode.CONNECT_ERROR) 420 || code.equals(ResultCode.SERVER_DOWN) 421 || code.equals(ResultCode.TIMEOUT) 422 || code.equals(ResultCode.UNAVAILABLE); 423 } 424 425 426 /** 427 * Parses the specified LDAP search result for a page cookie. 428 * 429 * @param sr The LDAP search result. Must not be {@code null}. 430 * 431 * @return The page cookie, {@code null} if not found or undefined. 432 */ 433 private static ASN1OctetString parsePageCookie(final SearchResult sr) { 434 435 Control control = sr.getResponseControl(SimplePagedResultsControl.PAGED_RESULTS_OID); 436 437 if (control instanceof SimplePagedResultsControl) { 438 SimplePagedResultsControl spr = (SimplePagedResultsControl)control; 439 return spr.getCookie(); 440 } else { 441 return null; 442 } 443 } 444 445 446 /** 447 * Performs an LDAP search with the specified request. 448 * 449 * @param searchRequest The LDAP search request. Must not be 450 * {@code null}. 451 * @param appendable Collects the matching directory entries. Must 452 * not be {@code null}. 453 */ 454 private void doSearch(final SearchRequest searchRequest, final Appendable<ReadOnlyEntry> appendable) { 455 456 // Check out connection from pool 457 final LDAPConnection connection; 458 459 try { 460 connection = ldapConnPool.getConnection(); 461 } catch (LDAPException e) { 462 throw new PersistenceException(e.getMessage(), e); 463 } 464 465 ASN1OctetString cookie = null; 466 467 do { 468 // Set paging cookie if returned from a previous iteration 469 searchRequest.replaceControl(new SimplePagedResultsControl(config.ldapDirectory.pageSize, cookie)); 470 471 final SearchResult searchResult; 472 Timer.Context timerCtx = ldapTimers.searchTimer.time(); 473 474 try { 475 searchResult = connection.search(searchRequest); 476 477 } catch (LDAPSearchException e) { 478 479 String msg = "[AS0109] LDAP search " + searchRequest.getFilter() + " failed: " + e.getMessage(); 480 481 if (indicatesConnectionException(e)) { 482 483 // Assume connection is unusable 484 ldapConnPool.releaseDefunctConnection(connection); 485 486 } else { 487 // Assume connection is still usable 488 ldapConnPool.releaseConnection(connection); 489 } 490 491 throw new PersistenceException(msg, e); 492 } finally { 493 timerCtx.stop(); 494 } 495 496 cookie = parsePageCookie(searchResult); 497 498 searchResult.getSearchEntries().forEach(appendable::append); 499 500 } while (cookie != null && cookie.getValueLength() > 0); 501 502 ldapConnPool.releaseConnection(connection); 503 } 504 505 506 /** 507 * Counts the number of entries under the base DN. 508 * 509 * @return The entry count. 510 */ 511 public int countEntries() { 512 513 SearchRequest request = new SearchRequest( 514 config.ldapDirectory.baseDN.toString(), 515 SearchScope.ONE, 516 MATCH_ANY_FILTER, 517 SearchRequest.NO_ATTRIBUTES); 518 519 final AtomicInteger count = new AtomicInteger(); 520 521 doSearch(request, entry -> count.incrementAndGet()); 522 523 return count.intValue(); 524 } 525 526 527 /** 528 * Deletes all entries under the base DN. 529 * 530 * @return The number of deleted entries, zero if none found. 531 */ 532 public int deleteEntries() { 533 534 SearchRequest request = new SearchRequest( 535 config.ldapDirectory.baseDN.toString(), 536 SearchScope.ONE, 537 MATCH_ANY_FILTER, 538 SearchRequest.NO_ATTRIBUTES); 539 540 List<String> entryDNs = new LinkedList<>(); 541 542 doSearch(request, entry -> entryDNs.add(entry.getDN())); 543 544 int count = 0; 545 546 for(String dn: entryDNs) { 547 548 try { 549 if (deleteEntry(new DN(dn))) { 550 ++count; 551 } 552 } catch (LDAPException e) { 553 throw new PersistenceException(e.getMessage(), e); 554 } 555 } 556 557 return count; 558 } 559 560 561 /** 562 * Shuts down this LDAP connector by releasing any associated resources 563 * (LDAP connection pool). 564 */ 565 public void shutdown() { 566 567 ldapConnPool.close(); 568 569 if (ldapConnPool.isClosed()) { 570 Loggers.MAIN_LOG.info("[IL0107] Shut down LDAP connector for {} cache", cacheName); 571 } else { 572 Loggers.MAIN_LOG.error("[IL0108] Attempted to shut down LDAP connector for {} cache, detected unreleased LDAP connections", cacheName); 573 } 574 } 575}