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 */ 020 021package org.nuxeo.ecm.directory.ldap; 022 023import java.io.IOException; 024import java.io.Serializable; 025import java.text.ParseException; 026import java.text.SimpleDateFormat; 027import java.util.ArrayList; 028import java.util.Arrays; 029import java.util.Calendar; 030import java.util.Collection; 031import java.util.Collections; 032import java.util.Date; 033import java.util.HashMap; 034import java.util.LinkedList; 035import java.util.List; 036import java.util.Map; 037import java.util.Properties; 038import java.util.Set; 039import java.util.SimpleTimeZone; 040 041import javax.naming.Context; 042import javax.naming.LimitExceededException; 043import javax.naming.NameNotFoundException; 044import javax.naming.NamingEnumeration; 045import javax.naming.NamingException; 046import javax.naming.SizeLimitExceededException; 047import javax.naming.directory.Attribute; 048import javax.naming.directory.Attributes; 049import javax.naming.directory.BasicAttribute; 050import javax.naming.directory.BasicAttributes; 051import javax.naming.directory.DirContext; 052import javax.naming.directory.SearchControls; 053import javax.naming.directory.SearchResult; 054import javax.naming.ldap.InitialLdapContext; 055 056import org.apache.commons.lang3.StringUtils; 057import org.apache.commons.logging.Log; 058import org.apache.commons.logging.LogFactory; 059import org.nuxeo.ecm.core.api.Blob; 060import org.nuxeo.ecm.core.api.Blobs; 061import org.nuxeo.ecm.core.api.DocumentModel; 062import org.nuxeo.ecm.core.api.DocumentModelList; 063import org.nuxeo.ecm.core.api.PropertyException; 064import org.nuxeo.ecm.core.api.RecoverableClientException; 065import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl; 066import org.nuxeo.ecm.core.api.security.SecurityConstants; 067import org.nuxeo.ecm.core.query.sql.model.OrderByList; 068import org.nuxeo.ecm.core.query.sql.model.QueryBuilder; 069import org.nuxeo.ecm.core.schema.types.Field; 070import org.nuxeo.ecm.core.schema.types.SimpleTypeImpl; 071import org.nuxeo.ecm.core.schema.types.Type; 072import org.nuxeo.ecm.core.utils.SIDGenerator; 073import org.nuxeo.ecm.directory.AbstractDirectory; 074import org.nuxeo.ecm.directory.BaseSession; 075import org.nuxeo.ecm.directory.DirectoryException; 076import org.nuxeo.ecm.directory.DirectoryFieldMapper; 077import org.nuxeo.ecm.directory.EntryAdaptor; 078import org.nuxeo.ecm.directory.PasswordHelper; 079 080/** 081 * This class represents a session against an LDAPDirectory. 082 * 083 * @author Olivier Grisel <[email protected]> 084 */ 085public class LDAPSession extends BaseSession { 086 087 protected static final String MISSING_ID_LOWER_CASE = "lower"; 088 089 protected static final String MISSING_ID_UPPER_CASE = "upper"; 090 091 private static final Log log = LogFactory.getLog(LDAPSession.class); 092 093 // set to false for debugging 094 private static final boolean HIDE_PASSWORD_IN_LOGS = true; 095 096 protected DirContext dirContext; 097 098 protected final String idAttribute; 099 100 protected final String idCase; 101 102 protected final String searchBaseDn; 103 104 protected final Set<String> emptySet = Collections.emptySet(); 105 106 protected final String sid; 107 108 protected final String rdnAttribute; 109 110 protected final String rdnField; 111 112 protected final String passwordHashAlgorithm; 113 114 public LDAPSession(LDAPDirectory directory) { 115 super(directory, LDAPReference.class); 116 DirectoryFieldMapper fieldMapper = directory.getFieldMapper(); 117 idAttribute = fieldMapper.getBackendField(getIdField()); 118 LDAPDirectoryDescriptor descriptor = directory.getDescriptor(); 119 idCase = descriptor.getIdCase(); 120 sid = String.valueOf(SIDGenerator.next()); 121 searchBaseDn = descriptor.getSearchBaseDn(); 122 substringMatchType = descriptor.getSubstringMatchType(); 123 rdnAttribute = descriptor.getRdnAttribute(); 124 rdnField = directory.getFieldMapper().getDirectoryField(rdnAttribute); 125 passwordHashAlgorithm = descriptor.passwordHashAlgorithm; 126 permissions = descriptor.permissions; 127 } 128 129 @Override 130 public LDAPDirectory getDirectory() { 131 return (LDAPDirectory) directory; 132 } 133 134 public DirContext getContext() { 135 if (dirContext == null) { 136 // Initialize directory context lazily 137 LDAPDirectory ldapDirectory = (LDAPDirectory) directory; 138 ContextProvider testServer = ldapDirectory.getTestServer(); 139 DirContext context = testServer == null ? ldapDirectory.createContext() : testServer.getContext(); 140 dirContext = LdapRetryHandler.wrap(context, ldapDirectory.getServer().getRetries()); 141 } 142 return dirContext; 143 } 144 145 @Override 146 protected DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap) { 147 // Make a copy of fieldMap to avoid modifying it 148 fieldMap = new HashMap<>(fieldMap); 149 150 LDAPDirectoryDescriptor descriptor = getDirectory().getDescriptor(); 151 List<String> referenceFieldList = new LinkedList<>(); 152 try { 153 String dn = String.format("%s=%s,%s", rdnAttribute, fieldMap.get(rdnField), descriptor.getCreationBaseDn()); 154 Attributes attrs = new BasicAttributes(); 155 Attribute attr; 156 157 List<String> mandatoryAttributes = getMandatoryAttributes(); 158 for (String mandatoryAttribute : mandatoryAttributes) { 159 attr = new BasicAttribute(mandatoryAttribute); 160 attr.add(" "); 161 attrs.put(attr); 162 } 163 164 String[] creationClasses = descriptor.getCreationClasses(); 165 if (creationClasses.length != 0) { 166 attr = new BasicAttribute("objectclass"); 167 for (String creationClasse : creationClasses) { 168 attr.add(creationClasse); 169 } 170 attrs.put(attr); 171 } 172 173 for (String fieldId : fieldMap.keySet()) { 174 String backendFieldId = getDirectory().getFieldMapper().getBackendField(fieldId); 175 if (fieldId.equals(getPasswordField())) { 176 attr = new BasicAttribute(backendFieldId); 177 String password = (String) fieldMap.get(fieldId); 178 password = PasswordHelper.hashPassword(password, passwordHashAlgorithm); 179 attr.add(password); 180 attrs.put(attr); 181 } else if (getDirectory().isReference(fieldId)) { 182 List<org.nuxeo.ecm.directory.Reference> references = directory.getReferences(fieldId); 183 if (references.size() > 1) { 184 // not supported 185 } else { 186 org.nuxeo.ecm.directory.Reference reference = references.get(0); 187 if (reference instanceof LDAPReference) { 188 attr = new BasicAttribute(((LDAPReference) reference).getStaticAttributeId()); 189 attr.add(descriptor.getEmptyRefMarker()); 190 attrs.put(attr); 191 } 192 } 193 referenceFieldList.add(fieldId); 194 } else if (LDAPDirectory.DN_SPECIAL_ATTRIBUTE_KEY.equals(backendFieldId)) { 195 // ignore special DN field 196 log.warn(String.format("field %s is mapped to read only DN field: ignored", fieldId)); 197 } else { 198 Object value = fieldMap.get(fieldId); 199 if ((value != null) && !value.equals("") && !Collections.emptyList().equals(value)) { 200 attrs.put(getAttributeValue(fieldId, value)); 201 } 202 } 203 } 204 205 if (log.isDebugEnabled()) { 206 Attributes logAttrs; 207 if (HIDE_PASSWORD_IN_LOGS && attrs.get(getPasswordField()) != null) { 208 logAttrs = (Attributes) attrs.clone(); 209 logAttrs.put(getPasswordField(), "********"); // hide password in logs 210 } else { 211 logAttrs = attrs; 212 } 213 String idField = getIdField(); 214 log.debug(String.format("LDAPSession.createEntry(%s=%s): LDAP bind dn='%s' attrs='%s' [%s]", idField, 215 fieldMap.get(idField), dn, logAttrs, this)); 216 } 217 getContext().bind(dn, null, attrs); 218 219 String dnFieldName = getDirectory().getFieldMapper() 220 .getDirectoryField(LDAPDirectory.DN_SPECIAL_ATTRIBUTE_KEY); 221 if (getDirectory().getSchemaFieldMap().containsKey(dnFieldName)) { 222 // add the DN special attribute to the fieldmap of the new 223 // entry 224 fieldMap.put(dnFieldName, dn); 225 } 226 } catch (NamingException e) { 227 handleException(e, "createEntry failed"); 228 return null; 229 } 230 231 return fieldMapToDocumentModel(fieldMap); 232 } 233 234 @Override 235 protected List<String> updateEntryWithoutReferences(DocumentModel docModel) { 236 List<String> updateList = new ArrayList<>(); 237 List<String> referenceFieldList = new LinkedList<>(); 238 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 239 try { 240 for (String fieldName : schemaFieldMap.keySet()) { 241 if (!docModel.getPropertyObject(schemaName, fieldName).isDirty()) { 242 continue; 243 } 244 if (getDirectory().isReference(fieldName)) { 245 referenceFieldList.add(fieldName); 246 } else { 247 updateList.add(fieldName); 248 } 249 } 250 251 if (!isReadOnlyEntry(docModel) && !updateList.isEmpty()) { 252 Attributes attrs = new BasicAttributes(); 253 SearchResult ldapEntry = getLdapEntry(docModel.getId()); 254 if (ldapEntry == null) { 255 throw new DirectoryException(docModel.getId() + " not found"); 256 } 257 Attributes oldattrs = ldapEntry.getAttributes(); 258 String dn = ldapEntry.getNameInNamespace(); 259 Attributes attrsToDel = new BasicAttributes(); 260 for (String f : updateList) { 261 Object value = docModel.getProperty(schemaName, f); 262 String backendField = getDirectory().getFieldMapper().getBackendField(f); 263 if (LDAPDirectory.DN_SPECIAL_ATTRIBUTE_KEY.equals(backendField)) { 264 // skip special LDAP DN field that is readonly 265 log.warn(String.format("field %s is mapped to read only DN field: ignored", f)); 266 continue; 267 } 268 if (value == null || value.equals("")) { 269 Attribute objectClasses = oldattrs.get("objectClass"); 270 Attribute attr; 271 if (getMandatoryAttributes(objectClasses).contains(backendField)) { 272 attr = new BasicAttribute(backendField); 273 // XXX: this might fail if the mandatory attribute 274 // is typed integer for instance 275 attr.add(" "); 276 attrs.put(attr); 277 } else if (oldattrs.get(backendField) != null) { 278 attr = new BasicAttribute(backendField); 279 attr.add(oldattrs.get(backendField).get()); 280 attrsToDel.put(attr); 281 } 282 } else if (f.equals(getPasswordField())) { 283 // The password has been updated, it has to be encrypted 284 Attribute attr = new BasicAttribute(backendField); 285 attr.add(PasswordHelper.hashPassword((String) value, passwordHashAlgorithm)); 286 attrs.put(attr); 287 } else { 288 attrs.put(getAttributeValue(f, value)); 289 } 290 } 291 292 if (log.isDebugEnabled()) { 293 log.debug( 294 String.format( 295 "LDAPSession.updateEntry(%s): LDAP modifyAttributes dn='%s' " 296 + "mod_op='REMOVE_ATTRIBUTE' attr='%s' [%s]", 297 docModel, dn, attrsToDel, this)); 298 } 299 getContext().modifyAttributes(dn, DirContext.REMOVE_ATTRIBUTE, attrsToDel); 300 301 if (log.isDebugEnabled()) { 302 log.debug(String.format("LDAPSession.updateEntry(%s): LDAP modifyAttributes dn='%s' " 303 + "mod_op='REPLACE_ATTRIBUTE' attr='%s' [%s]", docModel, dn, attrs, this)); 304 } 305 getContext().modifyAttributes(dn, DirContext.REPLACE_ATTRIBUTE, attrs); 306 } 307 } catch (NamingException e) { 308 handleException(e, "updateEntry failed:"); 309 } 310 return referenceFieldList; 311 } 312 313 @Override 314 public void deleteEntryWithoutReferences(String id) { 315 try { 316 SearchResult result = getLdapEntry(id, false); 317 318 if (log.isDebugEnabled()) { 319 log.debug(String.format("LDAPSession.deleteEntry(%s): LDAP destroySubcontext dn='%s' [%s]", id, 320 result.getNameInNamespace(), this)); 321 } 322 getContext().destroySubcontext(result.getNameInNamespace()); 323 } catch (NamingException e) { 324 handleException(e, "deleteEntry failed for: " + id); 325 } 326 } 327 328 @Override 329 public boolean hasEntry(String id) { 330 try { 331 // TODO: check directory cache first 332 return getLdapEntry(id) != null; 333 } catch (NamingException e) { 334 throw new DirectoryException("hasEntry failed: " + e.getMessage(), e); 335 } 336 } 337 338 protected SearchResult getLdapEntry(String id) throws NamingException { 339 return getLdapEntry(id, false); 340 } 341 342 protected SearchResult getLdapEntry(String id, boolean fetchAllAttributes) throws NamingException { 343 if (StringUtils.isEmpty(id)) { 344 log.warn("The application should not " + "query for entries with an empty id " + "=> return no results"); 345 return null; 346 } 347 String filterExpr; 348 String baseFilter = getDirectory().getBaseFilter(); 349 if (baseFilter.startsWith("(")) { 350 filterExpr = String.format("(&(%s={0})%s)", idAttribute, baseFilter); 351 } else { 352 filterExpr = String.format("(&(%s={0})(%s))", idAttribute, baseFilter); 353 } 354 String[] filterArgs = { id }; 355 SearchControls scts = getDirectory().getSearchControls(fetchAllAttributes); 356 357 if (log.isDebugEnabled()) { 358 log.debug(String.format( 359 "LDAPSession.getLdapEntry(%s, %s): LDAP search base='%s' filter='%s' " 360 + " args='%s' scope='%s' [%s]", 361 id, fetchAllAttributes, searchBaseDn, filterExpr, id, scts.getSearchScope(), this)); 362 } 363 NamingEnumeration<SearchResult> results; 364 try { 365 results = getContext().search(searchBaseDn, filterExpr, filterArgs, scts); 366 } catch (NameNotFoundException nnfe) { 367 // sometimes ActiveDirectory have some query fail with: LDAP: 368 // error code 32 - 0000208D: NameErr: DSID-031522C9, problem 369 // 2001 (NO_OBJECT). 370 // To keep the application usable return no results instead of 371 // crashing but log the error so that the AD admin 372 // can fix the issue. 373 log.error("Unexpected response from server while performing query: " + nnfe.getMessage(), nnfe); 374 return null; 375 } 376 377 if (!results.hasMore()) { 378 log.debug("Entry not found: " + id); 379 return null; 380 } 381 SearchResult result = results.next(); 382 try { 383 String dn = result.getNameInNamespace(); 384 if (results.hasMore()) { 385 result = results.next(); 386 String dn2 = result.getNameInNamespace(); 387 String msg = String.format( 388 "Unable to fetch entry for '%s': found more than one match," + " for instance: '%s' and '%s'", 389 id, dn, dn2); 390 log.error(msg); 391 // ignore entries that are ambiguous while giving enough info 392 // in the logs to let the LDAP admin be able to fix the issue 393 return null; 394 } 395 if (log.isDebugEnabled()) { 396 log.debug(String.format( 397 "LDAPSession.getLdapEntry(%s, %s): LDAP search base='%s' filter='%s' " 398 + " args='%s' scope='%s' => found: %s [%s]", 399 id, fetchAllAttributes, searchBaseDn, filterExpr, id, scts.getSearchScope(), dn, this)); 400 } 401 } catch (UnsupportedOperationException e) { 402 // ignore unsupported operation thrown by the Apache DS server in 403 // the tests in embedded mode 404 } 405 return result; 406 } 407 408 protected void handleException(Exception e, String message) { 409 LdapExceptionProcessor processor = getDirectory().getDescriptor().getExceptionProcessor(); 410 411 RecoverableClientException userException = processor.extractRecoverableException(e); 412 if (userException != null) { 413 throw userException; 414 } 415 throw new DirectoryException(message + " " + e.getMessage(), e); 416 417 } 418 419 @Override 420 public void deleteEntry(String id, Map<String, String> map) { 421 log.warn("Calling deleteEntry extended on LDAP directory"); 422 deleteEntry(id); 423 } 424 425 @Override 426 public DocumentModel getEntryFromSource(String id, boolean fetchReferences) { 427 try { 428 SearchResult result = getLdapEntry(id, false); 429 if (result == null) { 430 return null; 431 } 432 return ldapResultToDocumentModel(result, id, fetchReferences); 433 } catch (NamingException e) { 434 throw new DirectoryException("getEntry failed: " + e.getMessage(), e); 435 } 436 } 437 438 @Override 439 public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy, 440 boolean fetchReferences, int limit, int offset) { 441 if (!hasPermission(SecurityConstants.READ)) { 442 return new DocumentModelListImpl(); 443 } 444 try { 445 // building the query using filterExpr / filterArgs to 446 // escape special characters and to fulltext search only on 447 // the explicitly specified fields 448 String[] filters = new String[filter.size()]; 449 String[] filterArgs = new String[filter.size()]; 450 451 if (fulltext == null) { 452 fulltext = Collections.emptySet(); 453 } 454 455 int index = 0; 456 for (String fieldName : filter.keySet()) { 457 if (getDirectory().isReference(fieldName)) { 458 log.warn(fieldName + " is a reference and will be ignored as a query criterion"); 459 continue; 460 } 461 462 String backendFieldName = getDirectory().getFieldMapper().getBackendField(fieldName); 463 Object fieldValue = filter.get(fieldName); 464 465 StringBuilder currentFilter = new StringBuilder(); 466 currentFilter.append("("); 467 if (fieldValue == null) { 468 currentFilter.append("!(").append(backendFieldName).append("=*)"); 469 } else if ("".equals(fieldValue)) { 470 if (fulltext.contains(fieldName)) { 471 currentFilter.append(backendFieldName).append("=*"); 472 } else { 473 currentFilter.append("!(").append(backendFieldName).append("=*)"); 474 } 475 } else { 476 currentFilter.append(backendFieldName).append("="); 477 if (fulltext.contains(fieldName)) { 478 switch (substringMatchType) { 479 case subinitial: 480 currentFilter.append("{").append(index).append("}*"); 481 break; 482 case subfinal: 483 currentFilter.append("*{").append(index).append("}"); 484 break; 485 case subany: 486 currentFilter.append("*{").append(index).append("}*"); 487 break; 488 } 489 } else { 490 currentFilter.append("{").append(index).append("}"); 491 } 492 } 493 currentFilter.append(")"); 494 filters[index] = currentFilter.toString(); 495 if (fieldValue != null && !"".equals(fieldValue)) { 496 if (fieldValue instanceof Blob) { 497 // filter arg could be a sequence of \xx where xx is the 498 // hexadecimal value of the byte 499 log.warn("Binary search is not supported"); 500 } else { 501 // XXX: what kind of Objects can we get here? Is 502 // toString() enough? 503 filterArgs[index] = fieldValue.toString(); 504 } 505 } 506 index++; 507 } 508 String filterExpr = "(&" + getDirectory().getBaseFilter() + StringUtils.join(filters) + ')'; 509 SearchControls scts = getDirectory().getSearchControls(true); 510 511 if (log.isDebugEnabled()) { 512 log.debug(String.format( 513 "LDAPSession.query(...): LDAP search base='%s' filter='%s' args='%s' scope='%s' [%s]", 514 searchBaseDn, filterExpr, StringUtils.join(filterArgs, ","), scts.getSearchScope(), this)); 515 } 516 try { 517 NamingEnumeration<SearchResult> results = getContext().search(searchBaseDn, filterExpr, filterArgs, 518 scts); 519 DocumentModelList entries = ldapResultsToDocumentModels(results, fetchReferences); 520 521 if (orderBy != null && !orderBy.isEmpty()) { 522 getDirectory().orderEntries(entries, orderBy); 523 } 524 return applyQueryLimits(entries, limit, offset); 525 } catch (NameNotFoundException nnfe) { 526 // sometimes ActiveDirectory have some query fail with: LDAP: 527 // error code 32 - 0000208D: NameErr: DSID-031522C9, problem 528 // 2001 (NO_OBJECT). 529 // To keep the application usable return no results instead of 530 // crashing but log the error so that the AD admin 531 // can fix the issue. 532 log.error("Unexpected response from server while performing query: " + nnfe.getMessage(), nnfe); 533 return new DocumentModelListImpl(); 534 } 535 } catch (LimitExceededException e) { 536 throw new org.nuxeo.ecm.directory.SizeLimitExceededException(e); 537 } catch (NamingException e) { 538 throw new DirectoryException("executeQuery failed", e); 539 } 540 } 541 542 @Override 543 public DocumentModelList query(QueryBuilder queryBuilder, boolean fetchReferences) { 544 if (!hasPermission(SecurityConstants.READ)) { 545 return new DocumentModelListImpl(); 546 } 547 if (FieldDetector.hasField(queryBuilder.predicate(), getPasswordField())) { 548 throw new DirectoryException("Cannot filter on password"); 549 } 550 queryBuilder = addTenantId(queryBuilder); 551 552 // build filter from query 553 LDAPFilterBuilder builder = new LDAPFilterBuilder(getDirectory()); 554 builder.walk(queryBuilder.predicate()); 555 String filter = builder.filter.toString(); 556 List<Serializable> filterParams = builder.params; 557 // add static filters 558 filter = getDirectory().addBaseFilter(filter); 559 560 int limit = Math.max(0, (int) queryBuilder.limit()); 561 int offset = Math.max(0, (int) queryBuilder.offset()); 562 boolean countTotal = queryBuilder.countTotal(); 563 // TODO orderby using SortControl 564 OrderByList orders = queryBuilder.orders(); 565 Map<String, String> orderBy = AbstractDirectory.makeOrderBy(orders); 566 SearchControls scts = getDirectory().getSearchControls(true); 567 568 if (log.isDebugEnabled()) { 569 log.debug( 570 String.format("LDAPSession.query(...): LDAP search base='%s' filter='%s' args='%s' scope='%s' [%s]", 571 searchBaseDn, filter, filterParams, scts.getSearchScope(), this)); 572 } 573 try { 574 NamingEnumeration<SearchResult> results = getContext().search(searchBaseDn, filter, filterParams.toArray(), 575 scts); 576 DocumentModelList entries = ldapResultsToDocumentModels(results, fetchReferences); 577 if (!orderBy.isEmpty()) { 578 getDirectory().orderEntries(entries, orderBy); 579 } 580 // TODO paging using PagedResultsControl 581 entries = applyQueryLimits(entries, limit, offset); 582 if ((limit != 0 || offset != 0) && !countTotal) { 583 // compat with other directories 584 ((DocumentModelListImpl) entries).setTotalSize(-2); 585 } 586 return entries; 587 } catch (NameNotFoundException nnfe) { 588 // sometimes ActiveDirectory have some query fail with: 589 // LDAP: error code 32 - 0000208D: NameErr: DSID-031522C9, problem 2001 (NO_OBJECT). 590 // To keep the application usable return no results instead of crashing but log the error 591 // so that the AD admin can fix the issue. 592 log.error("Unexpected response from server while performing query: " + nnfe.getMessage(), nnfe); 593 return new DocumentModelListImpl(); 594 } catch (LimitExceededException e) { 595 throw new org.nuxeo.ecm.directory.SizeLimitExceededException(e); 596 } catch (NamingException e) { 597 throw new DirectoryException("executeQuery failed", e); 598 } 599 } 600 601 @Override 602 public List<String> queryIds(QueryBuilder queryBuilder) { 603 if (!hasPermission(SecurityConstants.READ)) { 604 return Collections.emptyList(); 605 } 606 if (FieldDetector.hasField(queryBuilder.predicate(), getPasswordField())) { 607 throw new DirectoryException("Cannot filter on password"); 608 } 609 queryBuilder = addTenantId(queryBuilder); 610 611 // build filter from query 612 LDAPFilterBuilder builder = new LDAPFilterBuilder(getDirectory()); 613 builder.walk(queryBuilder.predicate()); 614 String filter = builder.filter.toString(); 615 List<Serializable> filterParams = builder.params; 616 // add static filters 617 filter = getDirectory().addBaseFilter(filter); 618 619 int limit = Math.max(0, (int) queryBuilder.limit()); 620 int offset = Math.max(0, (int) queryBuilder.offset()); 621 // TODO orderby using SortControl 622 OrderByList orders = queryBuilder.orders(); 623 boolean order = !orders.isEmpty(); 624 SearchControls scts = order ? getDirectory().getSearchControls(true) : getDirectory().getIdSearchControls(); 625 626 if (log.isDebugEnabled()) { 627 log.debug( 628 String.format("LDAPSession.query(...): LDAP search base='%s' filter='%s' args='%s' scope='%s' [%s]", 629 searchBaseDn, filter, filterParams, scts.getSearchScope(), this)); 630 } 631 try { 632 NamingEnumeration<SearchResult> results = getContext().search(searchBaseDn, filter, filterParams.toArray(), 633 scts); 634 List<String> ids = new ArrayList<>(); 635 DocumentModelList entries = ldapResultsToDocumentModels(results, false); 636 // order entries if needed 637 if (order) { 638 getDirectory().orderEntries(entries, AbstractDirectory.makeOrderBy(orders)); 639 } 640 entries.forEach(doc -> ids.add(doc.getId())); 641 // TODO paging using PagedResultsControl 642 return applyQueryLimits(ids, limit, offset); 643 } catch (NameNotFoundException nnfe) { 644 // sometimes ActiveDirectory have some query fail with: 645 // LDAP: error code 32 - 0000208D: NameErr: DSID-031522C9, problem 2001 (NO_OBJECT). 646 // To keep the application usable return no results instead of crashing but log the error 647 // so that the AD admin can fix the issue. 648 log.error("Unexpected response from server while performing query: " + nnfe.getMessage(), nnfe); 649 return Collections.emptyList(); 650 } catch (LimitExceededException e) { 651 throw new org.nuxeo.ecm.directory.SizeLimitExceededException(e); 652 } catch (NamingException e) { 653 throw new DirectoryException("executeQuery failed", e); 654 } 655 } 656 657 @Override 658 public void close() { 659 try { 660 getContext().close(); 661 } catch (NamingException e) { 662 throw new DirectoryException("close failed", e); 663 } finally { 664 getDirectory().removeSession(this); 665 } 666 } 667 668 protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) { 669 String id = String.valueOf(fieldMap.get(getIdField())); 670 try { 671 DocumentModel docModel = BaseSession.createEntryModel(sid, schemaName, id, fieldMap, isReadOnly()); 672 EntryAdaptor adaptor = getDirectory().getDescriptor().getEntryAdaptor(); 673 if (adaptor != null) { 674 docModel = adaptor.adapt(directory, docModel); 675 } 676 return docModel; 677 } catch (PropertyException e) { 678 log.error(e, e); 679 return null; 680 } 681 } 682 683 @SuppressWarnings("unchecked") 684 protected Object getFieldValue(Attribute attribute, String fieldName, String entryId, boolean fetchReferences) { 685 686 Field field = directory.getSchemaFieldMap().get(fieldName); 687 Type type = field.getType(); 688 if (type instanceof SimpleTypeImpl) { 689 // type with constraint 690 type = type.getSuperType(); 691 } 692 Object defaultValue = field.getDefaultValue(); 693 String typeName = type.getName(); 694 if (attribute == null) { 695 return defaultValue; 696 } 697 Object value; 698 try { 699 value = attribute.get(); 700 } catch (NamingException e) { 701 throw new DirectoryException("Could not fetch value for " + attribute, e); 702 } 703 if (value == null) { 704 return defaultValue; 705 } 706 String trimmedValue = value.toString().trim(); 707 if ("string".equals(typeName)) { 708 return trimmedValue; 709 } else if ("integer".equals(typeName) || "long".equals(typeName)) { 710 if ("".equals(trimmedValue)) { 711 return defaultValue; 712 } 713 try { 714 return Long.valueOf(trimmedValue); 715 } catch (NumberFormatException e) { 716 log.error(String.format( 717 "field %s of type %s has non-numeric value found on server: '%s' (ignoring and using default value instead)", 718 fieldName, typeName, trimmedValue)); 719 return defaultValue; 720 } 721 } else if (type.isListType()) { 722 List<String> parsedItems = new LinkedList<>(); 723 NamingEnumeration<Object> values = null; 724 try { 725 values = (NamingEnumeration<Object>) attribute.getAll(); 726 while (values.hasMore()) { 727 parsedItems.add(values.next().toString().trim()); 728 } 729 return parsedItems; 730 } catch (NamingException e) { 731 log.error(String.format( 732 "field %s of type %s has non list value found on server: '%s' (ignoring and using default value instead)", 733 fieldName, typeName, values != null ? values.toString() : trimmedValue)); 734 return defaultValue; 735 } finally { 736 if (values != null) { 737 try { 738 values.close(); 739 } catch (NamingException e) { 740 log.error(e, e); 741 } 742 } 743 } 744 } else if ("date".equals(typeName)) { 745 if ("".equals(trimmedValue)) { 746 return defaultValue; 747 } 748 try { 749 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss'Z'"); 750 dateFormat.setTimeZone(new SimpleTimeZone(0, "Z")); 751 Date date = dateFormat.parse(trimmedValue); 752 Calendar cal = Calendar.getInstance(); 753 cal.setTime(date); 754 return cal; 755 } catch (ParseException e) { 756 log.error(String.format( 757 "field %s of type %s has invalid value found on server: '%s' (ignoring and using default value instead)", 758 fieldName, typeName, trimmedValue)); 759 return defaultValue; 760 } 761 } else if ("content".equals(typeName)) { 762 return Blobs.createBlob((byte[]) value); 763 } else { 764 throw new DirectoryException("Field type not supported in directories: " + typeName); 765 } 766 } 767 768 @SuppressWarnings("unchecked") 769 protected Attribute getAttributeValue(String fieldName, Object value) { 770 Attribute attribute = new BasicAttribute(getDirectory().getFieldMapper().getBackendField(fieldName)); 771 Field field = directory.getSchemaFieldMap().get(fieldName); 772 if (field == null) { 773 String message = String.format("Invalid field name '%s' for directory '%s' with schema '%s'", fieldName, 774 directory.getName(), directory.getSchema()); 775 throw new DirectoryException(message); 776 } 777 Type type = field.getType(); 778 if (type instanceof SimpleTypeImpl) { 779 // type with constraint 780 type = type.getSuperType(); 781 } 782 String typeName = type.getName(); 783 784 if ("string".equals(typeName)) { 785 attribute.add(value); 786 } else if ("integer".equals(typeName) || "long".equals(typeName)) { 787 attribute.add(value.toString()); 788 } else if (type.isListType()) { 789 Collection<String> valueItems; 790 if (value instanceof String[]) { 791 valueItems = Arrays.asList((String[]) value); 792 } else if (value instanceof Collection) { 793 valueItems = (Collection<String>) value; 794 } else { 795 throw new DirectoryException(String.format("field %s with value %s does not match type %s", fieldName, 796 value.toString(), type.getName())); 797 } 798 for (String item : valueItems) { 799 attribute.add(item); 800 } 801 } else if ("date".equals(typeName)) { 802 Calendar cal = (Calendar) value; 803 Date date = cal.getTime(); 804 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss'Z'"); 805 dateFormat.setTimeZone(new SimpleTimeZone(0, "Z")); 806 attribute.add(dateFormat.format(date)); 807 } else if ("content".equals(typeName)) { 808 try { 809 attribute.add(((Blob) value).getByteArray()); 810 } catch (IOException e) { 811 throw new DirectoryException("Failed to get ByteArray value", e); 812 } 813 } else { 814 throw new DirectoryException("Field type not supported in directories: " + typeName); 815 } 816 817 return attribute; 818 } 819 820 protected DocumentModelList ldapResultsToDocumentModels(NamingEnumeration<SearchResult> results, 821 boolean fetchReferences) throws NamingException { 822 DocumentModelListImpl list = new DocumentModelListImpl(); 823 try { 824 while (results.hasMore()) { 825 SearchResult result = results.next(); 826 DocumentModel entry = ldapResultToDocumentModel(result, null, fetchReferences); 827 if (entry != null) { 828 list.add(entry); 829 } 830 } 831 } catch (SizeLimitExceededException e) { 832 if (list.isEmpty()) { 833 // the server did no send back the truncated results set, 834 // re-throw the exception to that the user interface can display 835 // the error message 836 throw e; 837 } 838 // mark the collect results as a truncated result list 839 log.debug("SizeLimitExceededException caught," + " return truncated results. Original message: " 840 + e.getMessage() + " explanation: " + e.getExplanation()); 841 list.setTotalSize(-2); 842 } finally { 843 results.close(); 844 } 845 log.debug("LDAP search returned " + list.size() + " results"); 846 return list; 847 } 848 849 protected DocumentModel ldapResultToDocumentModel(SearchResult result, String entryId, boolean fetchReferences) 850 throws NamingException { 851 Attributes attributes = result.getAttributes(); 852 String passwordFieldId = getPasswordField(); 853 Map<String, Object> fieldMap = new HashMap<>(); 854 855 Attribute attribute = attributes.get(idAttribute); 856 // NXP-2461: check that id field is filled + NXP-2730: make sure that 857 // entry id is the one returned from LDAP 858 if (attribute != null) { 859 Object entry = attribute.get(); 860 if (entry != null) { 861 entryId = entry.toString(); 862 } 863 } 864 // NXP-7136 handle id case 865 entryId = changeEntryIdCase(entryId, idCase); 866 867 if (entryId == null) { 868 // don't bother 869 return null; 870 } 871 for (String fieldName : directory.getSchemaFieldMap().keySet()) { 872 List<org.nuxeo.ecm.directory.Reference> references = directory.getReferences(fieldName); 873 if (references != null && references.size() > 0) { 874 if (fetchReferences) { 875 Map<String, List<String>> referencedIdsMap = new HashMap<>(); 876 for (org.nuxeo.ecm.directory.Reference reference : references) { 877 // reference resolution 878 List<String> referencedIds; 879 if (reference instanceof LDAPReference) { 880 // optim: use the current LDAPSession directly to 881 // provide the LDAP reference with the needed backend entries 882 LDAPReference ldapReference = (LDAPReference) reference; 883 referencedIds = ldapReference.getLdapTargetIds(attributes); 884 } else if (reference instanceof LDAPTreeReference) { 885 // TODO: optimize using the current LDAPSession 886 // directly to provide the LDAP reference with the 887 // needed backend entries (needs to implement getLdapTargetIds) 888 LDAPTreeReference ldapReference = (LDAPTreeReference) reference; 889 referencedIds = ldapReference.getTargetIdsForSource(entryId); 890 } else { 891 referencedIds = reference.getTargetIdsForSource(entryId); 892 } 893 referencedIds = new ArrayList<>(referencedIds); 894 Collections.sort(referencedIds); 895 if (referencedIdsMap.containsKey(fieldName)) { 896 referencedIdsMap.get(fieldName).addAll(referencedIds); 897 } else { 898 referencedIdsMap.put(fieldName, referencedIds); 899 } 900 } 901 fieldMap.put(fieldName, referencedIdsMap.get(fieldName)); 902 } 903 } else { 904 // manage directly stored fields 905 String attributeId = getDirectory().getFieldMapper().getBackendField(fieldName); 906 if (attributeId.equals(LDAPDirectory.DN_SPECIAL_ATTRIBUTE_KEY)) { 907 // this is the special DN readonly attribute 908 try { 909 fieldMap.put(fieldName, result.getNameInNamespace()); 910 } catch (UnsupportedOperationException e) { 911 // ignore ApacheDS partial implementation when running 912 // in embedded mode 913 } 914 } else { 915 // this is a regular attribute 916 attribute = attributes.get(attributeId); 917 if (fieldName.equals(passwordFieldId)) { 918 // do not try to fetch the password attribute 919 continue; 920 } else { 921 fieldMap.put(fieldName, getFieldValue(attribute, fieldName, entryId, fetchReferences)); 922 } 923 } 924 } 925 } 926 // check if the idAttribute was returned from the search. If not 927 // set it anyway, maybe changing its case if it's a String instance 928 String fieldId = getDirectory().getFieldMapper().getDirectoryField(idAttribute); 929 Object obj = fieldMap.get(fieldId); 930 if (obj == null) { 931 fieldMap.put(fieldId, changeEntryIdCase(entryId, getDirectory().getDescriptor().getMissingIdFieldCase())); 932 } else if (obj instanceof String) { 933 fieldMap.put(fieldId, changeEntryIdCase((String) obj, idCase)); 934 } 935 return fieldMapToDocumentModel(fieldMap); 936 } 937 938 protected String changeEntryIdCase(String id, String idFieldCase) { 939 if (MISSING_ID_LOWER_CASE.equals(idFieldCase)) { 940 return id.toLowerCase(); 941 } else if (MISSING_ID_UPPER_CASE.equals(idFieldCase)) { 942 return id.toUpperCase(); 943 } 944 // returns the unchanged id 945 return id; 946 } 947 948 @Override 949 public boolean authenticate(String username, String password) { 950 951 if (password == null || "".equals(password.trim())) { 952 // never use anonymous bind as a way to authenticate a user in 953 // Nuxeo EP 954 return false; 955 } 956 957 // lookup the user: fetch its dn 958 SearchResult entry; 959 try { 960 entry = getLdapEntry(username); 961 } catch (NamingException e) { 962 throw new DirectoryException("failed to fetch the ldap entry for " + username, e); 963 } 964 if (entry == null) { 965 // no such user => authentication failed 966 return false; 967 } 968 String dn = entry.getNameInNamespace(); 969 Properties env = (Properties) getDirectory().getContextProperties().clone(); 970 env.put(Context.SECURITY_PRINCIPAL, dn); 971 env.put(Context.SECURITY_CREDENTIALS, password); 972 973 InitialLdapContext authenticationDirContext = null; 974 try { 975 // creating a context does a bind 976 log.debug(String.format("LDAP bind dn='%s'", dn)); 977 authenticationDirContext = new InitialLdapContext(env, null); 978 // force reconnection to prevent from using a previous connection 979 // with an obsolete password (after an user has changed his 980 // password) 981 authenticationDirContext.reconnect(null); 982 log.debug("Bind succeeded, authentication ok"); 983 return true; 984 } catch (NamingException e) { 985 log.debug("Bind failed: " + e.getMessage()); 986 // authentication failed 987 return false; 988 } finally { 989 try { 990 if (authenticationDirContext != null) { 991 authenticationDirContext.close(); 992 } 993 } catch (NamingException e) { 994 log.error("Error closing authentication context when biding dn " + dn, e); 995 } 996 } 997 } 998 999 @Override 1000 public boolean isAuthenticating() { 1001 return directory.getSchemaFieldMap().containsKey(getPasswordField()); 1002 } 1003 1004 public boolean rdnMatchesIdField() { 1005 return getDirectory().getDescriptor().rdnAttribute.equals(idAttribute); 1006 } 1007 1008 @SuppressWarnings("unchecked") 1009 protected List<String> getMandatoryAttributes(Attribute objectClassesAttribute) { 1010 try { 1011 List<String> mandatoryAttributes = new ArrayList<>(); 1012 1013 DirContext schema = getContext().getSchema(""); 1014 List<String> objectClasses = new ArrayList<>(); 1015 if (objectClassesAttribute == null) { 1016 // use the creation classes as reference schema for this entry 1017 objectClasses.addAll(Arrays.asList(getDirectory().getDescriptor().getCreationClasses())); 1018 } else { 1019 // introspec the objectClass definitions to find the mandatory 1020 // attributes for this entry 1021 NamingEnumeration<Object> values = null; 1022 try { 1023 values = (NamingEnumeration<Object>) objectClassesAttribute.getAll(); 1024 while (values.hasMore()) { 1025 objectClasses.add(values.next().toString().trim()); 1026 } 1027 } catch (NamingException e) { 1028 throw new DirectoryException(e); 1029 } finally { 1030 if (values != null) { 1031 values.close(); 1032 } 1033 } 1034 } 1035 objectClasses.remove("top"); 1036 for (String creationClass : objectClasses) { 1037 Attributes attributes = schema.getAttributes("ClassDefinition/" + creationClass); 1038 Attribute attribute = attributes.get("MUST"); 1039 if (attribute != null) { 1040 NamingEnumeration<String> values = (NamingEnumeration<String>) attribute.getAll(); 1041 try { 1042 while (values.hasMore()) { 1043 String value = values.next(); 1044 mandatoryAttributes.add(value); 1045 } 1046 } finally { 1047 values.close(); 1048 } 1049 } 1050 } 1051 return mandatoryAttributes; 1052 } catch (NamingException e) { 1053 throw new DirectoryException("getMandatoryAttributes failed", e); 1054 } 1055 } 1056 1057 protected List<String> getMandatoryAttributes() { 1058 return getMandatoryAttributes(null); 1059 } 1060 1061 @Override 1062 // useful for the log function 1063 public String toString() { 1064 return String.format("LDAPSession '%s' for directory %s", sid, directory.getName()); 1065 } 1066 1067 @Override 1068 public DocumentModel createEntry(DocumentModel entry) { 1069 Map<String, Object> fieldMap = entry.getProperties(directory.getSchema()); 1070 Map<String, Object> simpleNameFieldMap = new HashMap<>(); 1071 for (Map.Entry<String, Object> fieldEntry : fieldMap.entrySet()) { 1072 String fieldKey = fieldEntry.getKey(); 1073 if (fieldKey.contains(":")) { 1074 fieldKey = fieldKey.split(":")[1]; 1075 } 1076 simpleNameFieldMap.put(fieldKey, fieldEntry.getValue()); 1077 } 1078 return createEntry(simpleNameFieldMap); 1079 } 1080 1081}