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