001/* 002 * (C) Copyright 2017-2018 Nuxeo (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 * Funsho David 018 * 019 */ 020 021package org.nuxeo.directory.mongodb; 022 023import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_ID; 024import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_SEQ; 025 026import java.io.Serializable; 027import java.util.ArrayList; 028import java.util.Calendar; 029import java.util.Collections; 030import java.util.HashMap; 031import java.util.LinkedList; 032import java.util.List; 033import java.util.Map; 034import java.util.Set; 035import java.util.regex.Pattern; 036import java.util.stream.Collectors; 037 038import org.apache.commons.lang3.StringUtils; 039import org.bson.Document; 040import org.bson.conversions.Bson; 041import org.nuxeo.ecm.core.api.DocumentModel; 042import org.nuxeo.ecm.core.api.DocumentModelList; 043import org.nuxeo.ecm.core.api.PropertyException; 044import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl; 045import org.nuxeo.ecm.core.api.model.Property; 046import org.nuxeo.ecm.core.api.security.SecurityConstants; 047import org.nuxeo.ecm.core.schema.types.Field; 048import org.nuxeo.ecm.core.schema.types.Type; 049import org.nuxeo.ecm.core.schema.types.primitives.IntegerType; 050import org.nuxeo.ecm.core.schema.types.primitives.LongType; 051import org.nuxeo.ecm.core.schema.types.primitives.StringType; 052import org.nuxeo.ecm.directory.BaseSession; 053import org.nuxeo.ecm.directory.DirectoryException; 054import org.nuxeo.ecm.directory.PasswordHelper; 055import org.nuxeo.ecm.directory.Reference; 056import org.nuxeo.ecm.directory.Session; 057import org.nuxeo.runtime.api.Framework; 058import org.nuxeo.runtime.mongodb.MongoDBConnectionHelper; 059import org.nuxeo.runtime.mongodb.MongoDBConnectionService; 060 061import com.mongodb.MongoWriteException; 062import com.mongodb.client.FindIterable; 063import com.mongodb.client.MongoCollection; 064import com.mongodb.client.MongoDatabase; 065import com.mongodb.client.model.FindOneAndUpdateOptions; 066import com.mongodb.client.model.ReturnDocument; 067import com.mongodb.client.model.Updates; 068import com.mongodb.client.result.DeleteResult; 069import com.mongodb.client.result.UpdateResult; 070 071/** 072 * MongoDB implementation of a {@link Session} 073 * 074 * @since 9.1 075 */ 076public class MongoDBSession extends BaseSession { 077 078 /** 079 * Prefix used to retrieve a MongoDB connection from {@link MongoDBConnectionService}. 080 * <p /> 081 * The connection id will be {@code directory/[DIRECTORY_NAME]}. 082 */ 083 public static final String DIRECTORY_CONNECTION_PREFIX = "directory/"; 084 085 protected final MongoDatabase database; 086 087 protected String countersCollectionName; 088 089 public MongoDBSession(MongoDBDirectory directory) { 090 super(directory, MongoDBReference.class); 091 MongoDBConnectionService mongoService = Framework.getService(MongoDBConnectionService.class); 092 database = mongoService.getDatabase(DIRECTORY_CONNECTION_PREFIX + directory.getDescriptor().name); 093 countersCollectionName = directory.getCountersCollectionName(); 094 } 095 096 @Override 097 public MongoDBDirectory getDirectory() { 098 return (MongoDBDirectory) directory; 099 } 100 101 @Override 102 protected DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap) { 103 // Make a copy of fieldMap to avoid modifying it 104 fieldMap = new HashMap<>(fieldMap); 105 106 // Filter out reference fields for creation as we keep it in a different collection 107 Map<String, Object> newDocMap = fieldMap.entrySet() 108 .stream() 109 .filter(entry -> getDirectory().getReferences(entry.getKey()) == null) 110 .collect(HashMap::new, (m, v) -> m.put(v.getKey(), v.getValue()), 111 HashMap::putAll); 112 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 113 String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName(); 114 String id; 115 if (autoincrementId) { 116 Document filter = MongoDBSerializationHelper.fieldMapToBson(MONGODB_ID, directoryName); 117 Bson update = Updates.inc(MONGODB_SEQ, 1L); 118 FindOneAndUpdateOptions options = new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER); 119 Long longId = getCollection(countersCollectionName).findOneAndUpdate(filter, update, options) 120 .getLong(MONGODB_SEQ); 121 fieldMap.put(idFieldName, longId); 122 newDocMap.put(idFieldName, longId); 123 id = String.valueOf(longId); 124 } else { 125 id = String.valueOf(fieldMap.get(idFieldName)); 126 if (hasEntry(id)) { 127 throw new DirectoryException(String.format("Entry with id %s already exists", id)); 128 } 129 } 130 try { 131 132 for (Map.Entry<String, Field> entry : schemaFieldMap.entrySet()) { 133 Field field = entry.getValue(); 134 if (field != null) { 135 String fieldName = field.getName().getPrefixedName(); 136 Object value = newDocMap.get(fieldName); 137 Type type = field.getType(); 138 if (value instanceof String) { 139 String v = (String) value; 140 if (type instanceof IntegerType) { 141 newDocMap.put(fieldName, Integer.valueOf(v)); 142 } else if (type instanceof LongType) { 143 newDocMap.put(fieldName, Long.valueOf(v)); 144 } 145 } else if (value instanceof Number) { 146 if (type instanceof LongType && value instanceof Integer) { 147 newDocMap.put(fieldName, Long.valueOf((Integer) value)); 148 } else if (type instanceof StringType) { 149 newDocMap.put(fieldName, value.toString()); 150 } 151 } 152 // Load default values if defined and not present in the map 153 if (!newDocMap.containsKey(fieldName)) { 154 Object defaultValue = field.getDefaultValue(); 155 if (defaultValue != null) { 156 newDocMap.put(fieldName, defaultValue); 157 } 158 } 159 } 160 } 161 Document bson = MongoDBSerializationHelper.fieldMapToBson(newDocMap); 162 String password = (String) newDocMap.get(getPasswordField()); 163 if (password != null && !PasswordHelper.isHashed(password)) { 164 password = PasswordHelper.hashPassword(password, passwordHashAlgorithm); 165 bson.append(getPasswordField(), password); 166 } 167 getCollection().insertOne(bson); 168 } catch (MongoWriteException e) { 169 throw new DirectoryException(e); 170 } 171 return createEntryModel(null, schemaName, id, fieldMap, isReadOnly()); 172 } 173 174 @Override 175 protected List<String> updateEntryWithoutReferences(DocumentModel docModel) throws DirectoryException { 176 Map<String, Object> fieldMap = new HashMap<>(); 177 List<String> referenceFieldList = new LinkedList<>(); 178 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 179 for (String fieldName : schemaFieldMap.keySet()) { 180 if (fieldName.equals(getIdField())) { 181 continue; 182 } 183 Property prop = docModel.getPropertyObject(schemaName, fieldName); 184 if (prop == null || !prop.isDirty() 185 || (fieldName.equals(getPasswordField()) && StringUtils.isEmpty((String) prop.getValue()))) { 186 continue; 187 } 188 if (getDirectory().isReference(fieldName)) { 189 referenceFieldList.add(fieldName); 190 } else { 191 Serializable value = prop.getValue(); 192 if (fieldName.equals(getPasswordField())) { 193 value = PasswordHelper.hashPassword((String) value, passwordHashAlgorithm); 194 } 195 if (value instanceof Calendar) { 196 value = ((Calendar) value).getTime(); 197 } 198 fieldMap.put(prop.getName(), value); 199 } 200 } 201 202 String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName(); 203 String id = docModel.getId(); 204 Document bson = MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id); 205 206 List<Bson> updates = fieldMap.entrySet().stream().map(e -> Updates.set(e.getKey(), e.getValue())).collect( 207 Collectors.toList()); 208 209 if (!updates.isEmpty()) { 210 try { 211 UpdateResult result = getCollection().updateOne(bson, Updates.combine(updates)); 212 // Throw an error if no document matched the update 213 if (!result.wasAcknowledged()) { 214 throw new DirectoryException( 215 "Error while updating the entry, the request has not been acknowledged by the server"); 216 } 217 if (result.getMatchedCount() == 0) { 218 throw new DirectoryException( 219 String.format("Error while updating the entry, no document was found with the id %s", id)); 220 } 221 } catch (MongoWriteException e) { 222 throw new DirectoryException(e); 223 } 224 } 225 return referenceFieldList; 226 } 227 228 @Override 229 public void deleteEntryWithoutReferences(String id) throws DirectoryException { 230 try { 231 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 232 DeleteResult result = getCollection().deleteOne( 233 MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id)); 234 if (!result.wasAcknowledged()) { 235 throw new DirectoryException( 236 "Error while deleting the entry, the request has not been acknowledged by the server"); 237 } 238 } catch (MongoWriteException e) { 239 throw new DirectoryException(e); 240 } 241 } 242 243 @Override 244 public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy, 245 boolean fetchReferences, int limit, int offset) throws DirectoryException { 246 247 if (!hasPermission(SecurityConstants.READ)) { 248 return new DocumentModelListImpl(); 249 } 250 251 // Remove password as it is not possible to do queries with it 252 filter.remove(getPasswordField()); 253 Document bson = buildQuery(filter, fulltext); 254 255 DocumentModelList entries = new DocumentModelListImpl(); 256 257 FindIterable<Document> results = getCollection().find(bson).skip(offset); 258 if (limit > 0) { 259 results.limit(limit); 260 } 261 for (Document resultDoc : results) { 262 263 // Cast object to document model 264 Map<String, Object> fieldMap = MongoDBSerializationHelper.bsonToFieldMap(resultDoc); 265 // Remove password from results 266 if (!readAllColumns) { 267 fieldMap.remove(getPasswordField()); 268 } 269 DocumentModel doc = fieldMapToDocumentModel(fieldMap); 270 271 if (fetchReferences) { 272 Map<String, List<String>> targetIdsMap = new HashMap<>(); 273 for (Reference reference : directory.getReferences()) { 274 List<String> targetIds; 275 if (reference instanceof MongoDBReference) { 276 MongoDBReference mongoReference = (MongoDBReference) reference; 277 targetIds = mongoReference.getTargetIdsForSource(doc.getId(), this); 278 } else { 279 targetIds = reference.getTargetIdsForSource(doc.getId()); 280 } 281 targetIds = new ArrayList<>(targetIds); 282 Collections.sort(targetIds); 283 String fieldName = reference.getFieldName(); 284 targetIdsMap.computeIfAbsent(fieldName, key -> new ArrayList<>()).addAll(targetIds); 285 } 286 for (Map.Entry<String, List<String>> entry : targetIdsMap.entrySet()) { 287 String fieldName = entry.getKey(); 288 List<String> targetIds = entry.getValue(); 289 try { 290 doc.setProperty(schemaName, fieldName, targetIds); 291 } catch (PropertyException e) { 292 throw new DirectoryException(e); 293 } 294 } 295 } 296 entries.add(doc); 297 } 298 299 if (orderBy != null && !orderBy.isEmpty()) { 300 getDirectory().orderEntries(entries, orderBy); 301 } 302 303 return entries; 304 } 305 306 protected Document buildQuery(Map<String, Serializable> fieldMap, Set<String> fulltext) { 307 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 308 Document bson = new Document(); 309 for (Map.Entry<String, Serializable> entry : fieldMap.entrySet()) { 310 Field field = schemaFieldMap.entrySet() 311 .stream() 312 .filter(e -> e.getValue().getName().getPrefixedName().equals(entry.getKey())) 313 .map(Map.Entry::getValue) 314 .findFirst() 315 .orElse(null); 316 317 Serializable v = entry.getValue(); 318 Object value = (field != null) ? MongoDBSerializationHelper.valueToBson(v, field.getType()) 319 : MongoDBSerializationHelper.valueToBson(v); 320 String key = entry.getKey(); 321 if (fulltext != null && fulltext.contains(key)) { 322 String val = String.valueOf(value); 323 if (val != null) { 324 val = val.replaceAll("%+", ".*"); 325 } 326 switch (substringMatchType) { 327 case subany: 328 addField(bson, key, Pattern.compile(val, Pattern.CASE_INSENSITIVE)); 329 break; 330 case subinitial: 331 addField(bson, key, Pattern.compile('^' + val, Pattern.CASE_INSENSITIVE)); 332 break; 333 case subfinal: 334 addField(bson, key, Pattern.compile(val + '$', Pattern.CASE_INSENSITIVE)); 335 break; 336 } 337 } else { 338 addField(bson, key, value); 339 } 340 } 341 return bson; 342 } 343 344 protected void addField(Document bson, String key, Object value) { 345 String keyFieldName = key; 346 Field field = directory.getSchemaFieldMap().get(key); 347 if (field != null) { 348 keyFieldName = field.getName().getPrefixedName(); 349 } 350 bson.put(keyFieldName, value); 351 } 352 353 @Override 354 public void close() throws DirectoryException { 355 getDirectory().removeSession(this); 356 } 357 358 @Override 359 public boolean authenticate(String username, String password) throws DirectoryException { 360 Document user = getCollection().find(MongoDBSerializationHelper.fieldMapToBson(getIdField(), username)).first(); 361 if (user == null) { 362 return false; 363 } 364 String storedPassword = user.getString(getPasswordField()); 365 return PasswordHelper.verifyPassword(password, storedPassword); 366 } 367 368 @Override 369 public boolean isAuthenticating() { 370 return directory.getSchemaFieldMap().containsKey(getPasswordField()); 371 } 372 373 @Override 374 public boolean hasEntry(String id) { 375 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 376 return getCollection().count(MongoDBSerializationHelper.fieldMapToBson(idFieldName, id)) > 0; 377 } 378 379 /** 380 * Retrieve a collection 381 * 382 * @param collection the collection name 383 * @return the MongoDB collection 384 */ 385 public MongoCollection<Document> getCollection(String collection) { 386 return database.getCollection(collection); 387 } 388 389 /** 390 * Retrieve the collection associated to this directory 391 * 392 * @return the MongoDB collection 393 */ 394 public MongoCollection<Document> getCollection() { 395 return getCollection(directoryName); 396 } 397 398 /** 399 * Check if the MongoDB server has the collection 400 * 401 * @param collection the collection name 402 * @return true if the server has the collection, false otherwise 403 */ 404 public boolean hasCollection(String collection) { 405 return MongoDBConnectionHelper.hasCollection(database, collection); 406 } 407 408 protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) { 409 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 410 if (!fieldMap.containsKey(idFieldName)) { 411 idFieldName = getIdField(); 412 } 413 String id = String.valueOf(fieldMap.get(idFieldName)); 414 return createEntryModel(null, schemaName, id, fieldMap, isReadOnly()); 415 } 416 417}