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