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 <ogrisel@nuxeo.com>
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}