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