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