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