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