001/* 002 * Copyright 2017 Anyware Services 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 */ 016package org.ametys.plugins.core.user; 017 018import java.io.File; 019import java.io.FileInputStream; 020import java.io.FileNotFoundException; 021import java.io.IOException; 022import java.nio.charset.StandardCharsets; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.HashMap; 027import java.util.List; 028import java.util.Locale; 029import java.util.Map; 030import java.util.Map.Entry; 031 032import org.apache.avalon.framework.component.Component; 033import org.apache.avalon.framework.service.ServiceException; 034import org.apache.avalon.framework.service.ServiceManager; 035import org.apache.avalon.framework.service.Serviceable; 036import org.apache.cocoon.servlet.multipart.Part; 037import org.apache.cocoon.servlet.multipart.PartOnDisk; 038import org.apache.cocoon.servlet.multipart.RejectedPart; 039import org.apache.commons.collections4.ListUtils; 040import org.apache.commons.io.FilenameUtils; 041import org.apache.commons.io.IOUtils; 042import org.apache.commons.io.input.BOMInputStream; 043import org.apache.commons.lang3.ArrayUtils; 044import org.apache.commons.lang3.StringUtils; 045 046import org.ametys.core.ObservationConstants; 047import org.ametys.core.observation.Event; 048import org.ametys.core.observation.ObservationManager; 049import org.ametys.core.user.CurrentUserProvider; 050import org.ametys.core.user.InvalidModificationException; 051import org.ametys.core.user.User; 052import org.ametys.core.user.UserIdentity; 053import org.ametys.core.user.UserManager; 054import org.ametys.core.util.I18nUtils; 055import org.ametys.runtime.i18n.I18nizableText; 056import org.ametys.runtime.parameter.Errors; 057import org.ametys.runtime.plugin.component.AbstractLogEnabled; 058 059/** 060 * Import users from a CSV or text file. 061 */ 062public class ImportUsers extends AbstractLogEnabled implements Component, Serviceable 063{ 064 /** Avalon Role */ 065 public static final String ROLE = ImportUsers.class.getName(); 066 067 private static final String[] _ALLOWED_EXTENSIONS = new String[] {"txt", "csv"}; 068 private static final String[] _COLUMNS = new String[] {"login", "password", "firstname", "lastname", "email", "salt"}; 069 private static final String _COLUMN_LOGIN = "login"; 070 private static final String _COLUMN_PASSWORD = "password"; 071 private static final String _COLUMN_FIRSTNAME = "firstname"; 072 private static final String _COLUMN_LASTNAME = "lastname"; 073 private static final String _COLUMN_EMAIL = "email"; 074 private static final String _COLUMN_SALT = "salt"; 075 076 private static final String _DEFAULT_HASHED_PASSWORD = "ImportedUserWithoutPassword"; 077 private static final String _DEFAULT_PASSWORD_SALT = "NotASalt"; 078 079 /** The subscribers DAO. */ 080 protected UserDAO _usersDao; 081 /** User Manager. */ 082 protected UserManager _userManager; 083 /** I18n utils */ 084 protected I18nUtils _i18nUtils; 085 /** Observation Manager */ 086 protected ObservationManager _observationManager; 087 /** Current User Provider */ 088 protected CurrentUserProvider _currentUserProvider; 089 090 @Override 091 public void service(ServiceManager smanager) throws ServiceException 092 { 093 _usersDao = (UserDAO) smanager.lookup(UserDAO.ROLE); 094 _userManager = (UserManager) smanager.lookup(UserManager.ROLE); 095 _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE); 096 _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE); 097 if (smanager.hasService(ObservationManager.ROLE)) 098 { 099 _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE); 100 } 101 } 102 103 /** 104 * Read a file from disk and add users inside in the requested population/directory 105 * @param filePart file 106 * @param populationId population Id 107 * @param userDirectoryId user directory 108 * @param cleanDirectory true to clear every user that was not in the import file 109 * @param hashedPasswords true if the passwords are already hashed in the file (salt must be in the file too, and password hashed with "saltPassword" with nothing in-between) 110 * @return a map containing success, addedCount, existingCount, errorCount, deletedCount, message 111 * success : true if success 112 * addedCount, existingCount, errorCount : number of imported user added, modified, an error occured (see server log) 113 * message : error message if something wrong happened (usually malformed file) 114 * @throws FileNotFoundException error reading the file 115 * @throws InvalidModificationException error modifying the directory 116 */ 117 public Map<String, Object> importUsers(Part filePart, String populationId, String userDirectoryId, boolean cleanDirectory, boolean hashedPasswords) throws FileNotFoundException, InvalidModificationException 118 { 119 Map<String, Object> result = new HashMap<>(); 120 121 if (filePart instanceof RejectedPart) 122 { 123 result.put("success", false); 124 result.put("error", "rejected-file"); 125 result.put("message", new I18nizableText("plugin.core", "PLUGINS_CORE_USERS_IMPORT_ERROR_REJECTED_FILE")); 126 return result; 127 } 128 129 PartOnDisk uploadedFilePart = (PartOnDisk) filePart; 130 File uploadedFile = (uploadedFilePart != null) ? uploadedFilePart.getFile() : null; 131 String filename = (uploadedFilePart != null) ? uploadedFilePart.getFileName().toLowerCase() : null; 132 133 if (!FilenameUtils.isExtension(filename, _ALLOWED_EXTENSIONS)) 134 { 135 result.put("success", false); 136 result.put("error", "invalid-extension"); 137 result.put("message", new I18nizableText("plugin.core", "PLUGINS_CORE_USERS_IMPORT_ERROR_INVALID_EXTENSION")); 138 return result; 139 } 140 141 try (FileInputStream fileIS = new FileInputStream(uploadedFile); 142 BOMInputStream bomIS = new BOMInputStream(fileIS)) 143 { 144 Map<String, List<UserIdentity>> parsedUsers = parseFile(bomIS, populationId, userDirectoryId, hashedPasswords); 145 146 List<UserIdentity> addedUsers = parsedUsers.get("added"); 147 List<UserIdentity> editedUsers = parsedUsers.get("edited"); 148 149 List<Map<String, String>> removedUsers; 150 if (cleanDirectory) 151 { 152 List<UserIdentity> usersToSave = ListUtils.union(addedUsers, editedUsers); 153 removedUsers = clearUserDirectory(populationId, userDirectoryId, usersToSave); 154 } 155 else 156 { 157 removedUsers = new ArrayList<>(); 158 } 159 160 result.put("success", true); 161 result.put("addedCount", addedUsers.size()); 162 result.put("existingCount", editedUsers.size()); 163 result.put("errorCount", parsedUsers.get("error").size()); 164 result.put("deletedCount", removedUsers.size()); 165 } 166 catch (IOException ioe) 167 { 168 result.put("success", false); 169 result.put("error", IOException.class.getName()); 170 result.put("message", ioe.getLocalizedMessage()); 171 } 172 catch (ImportUserActionException iuae) 173 { 174 result.put("success", false); 175 result.put("error", iuae.getErrorName()); 176 result.put("message", iuae.getI18nizableText()); 177 } 178 179 return result; 180 } 181 182 /** 183 * Remove all users in a userDirectory, avoiding those in usersToSave list 184 * @param populationId population Id 185 * @param userDirectoryId directory Id 186 * @param usersToSave list of users that need to be saved 187 * @return list of deleted users (containing login and populationId) 188 * @throws InvalidModificationException If modification is not possible 189 */ 190 protected List<Map<String, String>> clearUserDirectory(String populationId, String userDirectoryId, List<UserIdentity> usersToSave) throws InvalidModificationException 191 { 192 Map<String, Object> parameters = new HashMap<>(); 193 Collection<User> existingUsersInDirectory = _userManager.getUsersByDirectory(populationId, userDirectoryId, Integer.MAX_VALUE, 0, parameters); 194 List<Map<String, String>> usersToDelete = new ArrayList<>(); 195 UserIdentity currentUser = _usersDao._getCurrentUser(); 196 for (User user : existingUsersInDirectory) 197 { 198 UserIdentity ui = user.getIdentity(); 199 // If the user is not in the "save" list, and is not the current user, add it to the delete list 200 if (!usersToSave.contains(ui) && (currentUser == null || !currentUser.equals(ui))) 201 { 202 Map<String, String> userTodelete = new HashMap<>(); 203 userTodelete.put("login", ui.getLogin()); 204 userTodelete.put("populationId", ui.getPopulationId()); 205 usersToDelete.add(userTodelete); 206 } 207 } 208 _usersDao.deleteUsers(usersToDelete); 209 return usersToDelete; 210 } 211 212 /** 213 * Parse the file to add new users in the directory 214 * @param bomIS input stream 215 * @param populationId population Id 216 * @param userDirectoryId directory Id 217 * @param hashedPassword true if the password is hashed 218 * @return a map, containing added/edited/error, each containing the logins in each list 219 * @throws IOException if an error occurs 220 * @throws ImportUserActionException file parsing failed 221 */ 222 @SuppressWarnings("unchecked") 223 protected Map<String, List<UserIdentity>> parseFile(BOMInputStream bomIS, String populationId, String userDirectoryId, boolean hashedPassword) throws IOException, ImportUserActionException 224 { 225 Map<String, Integer> order = new HashMap<>(); 226 227 List<UserIdentity> addedUsers = new ArrayList<>(); 228 List<UserIdentity> editedUsers = new ArrayList<>(); 229 List<UserIdentity> errors = new ArrayList<>(); 230 231 boolean headerRead = false; 232 233 for (String line : IOUtils.readLines(bomIS, StandardCharsets.UTF_8)) 234 { 235 String[] columns = Arrays.stream(line.split(";")).map(StringUtils::normalizeSpace).toArray(String[]::new); 236 if (!headerRead) 237 { 238 _checkCsvHeader(columns, hashedPassword); 239 for (int i = 0; i < columns.length; i++) 240 { 241 String string = columns[i]; 242 order.put(string, i); 243 } 244 headerRead = true; 245 } 246 else 247 { 248 Map<String, String> untypedValues = new HashMap<>(); 249 String login = columns[order.get(_COLUMN_LOGIN)]; 250 UserIdentity userIdentity = new UserIdentity(login, populationId); 251 untypedValues.put("login", login); 252 untypedValues.put("firstname", columns[order.get(_COLUMN_FIRSTNAME)]); 253 untypedValues.put("lastname", columns[order.get(_COLUMN_LASTNAME)]); 254 untypedValues.put("email", columns[order.get(_COLUMN_EMAIL)]); 255 256 if (order.containsKey(_COLUMN_PASSWORD)) 257 { 258 untypedValues.put("password", columns[order.get(_COLUMN_PASSWORD)]); 259 if (hashedPassword) 260 { 261 untypedValues.put("salt", columns[order.get(_COLUMN_SALT)]); 262 untypedValues.put("clearText", "false"); 263 } 264 else 265 { 266 untypedValues.put("clearText", "true"); 267 } 268 } 269 270 try 271 { 272 User user = _userManager.getUser(populationId, login); 273 if (user == null) 274 { 275 if (!order.containsKey(_COLUMN_PASSWORD)) 276 { 277 // If there is no password column, the user is created with an invalid hashed password 278 // To connect, the user will have to reset it's password 279 untypedValues.put("password", _DEFAULT_HASHED_PASSWORD); 280 untypedValues.put("salt", _DEFAULT_PASSWORD_SALT); 281 untypedValues.put("clearText", "false"); 282 } 283 284 Map<String, Object> addedUser = _usersDao.addUser(populationId, userDirectoryId, untypedValues); 285 if (addedUser.containsKey("errors")) 286 { 287 Map<String, Errors> fieldErrors = (Map<String, Errors>) addedUser.get("errors"); 288 errors.add(userIdentity); 289 getLogger().error("Failed to import user from inputs [{}] for population '{}' and user directory '{}' with the following error(s):\n{}", line, populationId, userDirectoryId, _parseErrors(fieldErrors)); 290 } 291 else 292 { 293 addedUsers.add(userIdentity); 294 } 295 } 296 else 297 { 298 Map<String, Object> editedUser = _usersDao.editUser(populationId, untypedValues); 299 if (editedUser.containsKey("errors")) 300 { 301 Map<String, Errors> fieldErrors = (Map<String, Errors>) editedUser.get("errors"); 302 errors.add(userIdentity); 303 getLogger().error("Failed to edit user from inputs [{}] for population '{}' and user directory '{}' with the following error(s):\n{}", line, populationId, userDirectoryId, _parseErrors(fieldErrors)); 304 } 305 else 306 { 307 editedUsers.add(userIdentity); 308 } 309 } 310 } 311 catch (InvalidModificationException e) 312 { 313 errors.add(userIdentity); 314 getLogger().error("Unable to import user from inputs [{}] for population '{}' and user directory '{}'", line, populationId, userDirectoryId, e); 315 } 316 } 317 } 318 319 Map<String, Object> eventParams = new HashMap<>(); 320 eventParams.put(ObservationConstants.ARGS_USERS_ADDED, addedUsers); 321 eventParams.put(ObservationConstants.ARGS_USERS_UPDATED, editedUsers); 322 323 if (_observationManager != null) 324 { 325 _observationManager.notify(new Event(ObservationConstants.EVENT_USER_IMPORTED, _currentUserProvider.getUser(), eventParams)); 326 } 327 328 Map<String, List<UserIdentity>> result = new HashMap<>(); 329 result.put("added", addedUsers); 330 result.put("edited", editedUsers); 331 result.put("error", errors); 332 return result; 333 } 334 335 private String _parseErrors(Map<String, Errors> errors) 336 { 337 StringBuilder sb = new StringBuilder(); 338 339 for (Entry<String, Errors> entry : errors.entrySet()) 340 { 341 sb.append("[" + entry.getKey() + "] "); 342 List<I18nizableText> errorLabels = entry.getValue().getErrors(); 343 for (I18nizableText errorLabel : errorLabels) 344 { 345 sb.append(_i18nUtils.translate(errorLabel, Locale.ENGLISH.getLanguage())).append("; "); 346 } 347 } 348 return sb.toString(); 349 } 350 351 /** 352 * Check a line (the header) and throw an exception if not correct 353 * @param columns list of fields 354 * @param hashedPassword is the password in clear text ? (special case for 'salt' column) 355 * @throws ImportUserActionException thrown if error 356 */ 357 private void _checkCsvHeader(String[] columns, boolean hashedPassword) throws ImportUserActionException 358 { 359 for (String expectedColumn : _COLUMNS) 360 { 361 // Check if salt column is present even if the password is not hashed 362 if (!hashedPassword && expectedColumn.equals(_COLUMN_SALT) && ArrayUtils.contains(columns, expectedColumn)) 363 { 364 throw new ImportUserActionException("PLUGINS_CORE_USERS_IMPORT_ERROR_COLUMN_SALT_PRESENT", "column-salt"); 365 } 366 // If column not found (unless this is the password, or salt and password is not hashed) 367 if (!ArrayUtils.contains(columns, expectedColumn) && !expectedColumn.equals(_COLUMN_PASSWORD) && (hashedPassword || !expectedColumn.equals(_COLUMN_SALT))) 368 { 369 List<String> params = new ArrayList<>(1); 370 params.add(expectedColumn); 371 throw new ImportUserActionException("PLUGINS_CORE_USERS_IMPORT_ERROR_COLUMN_MISSING", "column-missing", params); 372 } 373 // If column is present, but multiple times 374 if (ArrayUtils.contains(columns, expectedColumn) && ArrayUtils.indexOf(columns, expectedColumn) != ArrayUtils.lastIndexOf(columns, expectedColumn)) 375 { 376 List<String> params = new ArrayList<>(1); 377 params.add(expectedColumn); 378 throw new ImportUserActionException("PLUGINS_CORE_USERS_IMPORT_ERROR_COLUMN_DUPLICATE", "column-duplicate", params); 379 } 380 } 381 } 382 383 /** 384 * Internal exception to throw with a i18n message 385 */ 386 private static class ImportUserActionException extends Exception 387 { 388 private I18nizableText _i18nizableText; 389 private String _errorName; 390 391 /** 392 * Create an exception with a parametrized message 393 * @param i18nkey i18n key 394 * @param errorName technical error name 395 * @param params parameters 396 */ 397 public ImportUserActionException(String i18nkey, String errorName, List<String> params) 398 { 399 this._i18nizableText = new I18nizableText("plugin.core", i18nkey, params); 400 this._errorName = errorName; 401 } 402 /** 403 * Create an exception with a message 404 * @param i18nkey i18n key 405 * @param errorName technical error name 406 */ 407 public ImportUserActionException(String i18nkey, String errorName) 408 { 409 this._i18nizableText = new I18nizableText("plugin.core", i18nkey); 410 this._errorName = errorName; 411 } 412 /** 413 * Get the I18nizableText 414 * @return I18nizableText representing the error message 415 */ 416 public I18nizableText getI18nizableText() 417 { 418 return this._i18nizableText; 419 } 420 /** 421 * Get the technical error name 422 * @return technical error name 423 */ 424 public String getErrorName() 425 { 426 return this._errorName; 427 } 428 } 429}