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