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