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