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}