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}