diff --git a/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java b/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java index 5d65a9d73c2..a306c142980 100644 --- a/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java +++ b/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java @@ -127,6 +127,11 @@ public class Settings { public static final String NODE_DEFAULT = "node/default"; public static final String NODE_NAME = "node/name"; + public static final String SYSTEM_SECURITY_PASSWORDENFORCEMENT_MINLENGH = "system/security/passwordEnforcement/minLength"; + public static final String SYSTEM_SECURITY_PASSWORDENFORCEMENT_MAXLENGH = "system/security/passwordEnforcement/maxLength"; + public static final String SYSTEM_SECURITY_PASSWORDENFORCEMENT_USEPATTERN = "system/security/passwordEnforcement/usePattern"; + public static final String SYSTEM_SECURITY_PASSWORDENFORCEMENT_PATTERN = "system/security/passwordEnforcement/pattern"; + public static class GNSetting { private String name; private boolean nullable; diff --git a/core/src/main/resources/org/fao/geonet/api/Messages.properties b/core/src/main/resources/org/fao/geonet/api/Messages.properties index 41cf0ccbc5c..326ea5aefce 100644 --- a/core/src/main/resources/org/fao/geonet/api/Messages.properties +++ b/core/src/main/resources/org/fao/geonet/api/Messages.properties @@ -131,3 +131,6 @@ self_registration_disabled=User self-registration is disabled recaptcha_not_valid=Recaptcha is not valid metadata.title.createdFromTemplate=Copy of template %s created at %s metadata.title.createdFromRecord=Copy of record %s created at %s +username.field.required=Username is required +password.field.length=Password size should be between {min} and {max} characters +password.field.invalid=Password must contain at least 1 uppercase, 1 lowercase, 1 number and 1 symbol. Symbols include: `~!@#$%^&*()-_=+[]{}\\|;:'",.<>/?'); diff --git a/core/src/main/resources/org/fao/geonet/api/Messages_fre.properties b/core/src/main/resources/org/fao/geonet/api/Messages_fre.properties index 97c81fb54e2..6dd76bcd778 100644 --- a/core/src/main/resources/org/fao/geonet/api/Messages_fre.properties +++ b/core/src/main/resources/org/fao/geonet/api/Messages_fre.properties @@ -104,3 +104,6 @@ self_registration_disabled=User self-registration is disabled recaptcha_not_valid=Recaptcha is not valid metadata.title.createdFromTemplate=Copie du mod\u00e8le %s cr\u00E9\u00E9e le %s metadata.title.createdFromRecord=Copie de la fiche %s cr\u00E9\u00E9e le %s +username.field.required=Username is required +password.field.length=Password size should be between {min} and {max} characters +password.field.invalid=Password must contain at least 1 uppercase, 1 lowercase, 1 number and 1 symbol. Symbols include: `~!@#$%^&*()-_=+[]{}\\|;:'",.<>/?'); diff --git a/services/src/main/java/org/fao/geonet/api/ApiUtils.java b/services/src/main/java/org/fao/geonet/api/ApiUtils.java index 8170086c26a..255d2b6e722 100644 --- a/services/src/main/java/org/fao/geonet/api/ApiUtils.java +++ b/services/src/main/java/org/fao/geonet/api/ApiUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2001-2016 Food and Agriculture Organization of the + * Copyright (C) 2001-2021 Food and Agriculture Organization of the * United Nations (FAO-UN), United Nations World Food Programme (WFP) * and United Nations Environment Programme (UNEP) * @@ -48,6 +48,8 @@ import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.util.StringUtils; +import org.springframework.validation.BindingResult; +import org.springframework.validation.ObjectError; import javax.imageio.ImageIO; import javax.servlet.http.HttpServletRequest; @@ -66,6 +68,9 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.Arrays; +import java.util.Iterator; +import java.util.MissingResourceException; +import java.util.ResourceBundle; import java.util.Set; @@ -330,4 +335,47 @@ public static void createFavicon(Image img, Path outFile) throws IOException { ImageIO.write(bimg, type, out); } } + + /** + * Process request validation, returning an string with the validation errors. + * + * @param bindingResult + * @param messages + */ + public static String processRequestValidation(BindingResult bindingResult, ResourceBundle messages) { + if (bindingResult.hasErrors()) { + java.util.List errorList = bindingResult.getAllErrors(); + + StringBuilder sb = new StringBuilder(); + Iterator it = errorList.iterator(); + while (it.hasNext()) { + ObjectError err = it.next(); + String msg = ""; + for(int i = 0; i < err.getCodes().length; i++) { + try { + msg = messages.getString(err.getCodes()[i]); + + if (!StringUtils.isEmpty(msg)) { + break; + } + } catch (MissingResourceException ex) { + // Ignore + } + } + + if (StringUtils.isEmpty(msg)) { + msg = err.getDefaultMessage(); + } + + sb.append(msg); + if (it.hasNext()) { + sb.append(", "); + } + } + + return sb.toString(); + } else { + return ""; + } + } } diff --git a/services/src/main/java/org/fao/geonet/api/users/RegisterApi.java b/services/src/main/java/org/fao/geonet/api/users/RegisterApi.java index b9be74e955f..55c8c72fedf 100644 --- a/services/src/main/java/org/fao/geonet/api/users/RegisterApi.java +++ b/services/src/main/java/org/fao/geonet/api/users/RegisterApi.java @@ -1,5 +1,5 @@ //============================================================================= -//=== Copyright (C) 2001-2007 Food and Agriculture Organization of the +//=== Copyright (C) 2001-2021 Food and Agriculture Organization of the //=== United Nations (FAO-UN), United Nations World Food Programme (WFP) //=== and United Nations Environment Programme (UNEP) //=== @@ -44,11 +44,15 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.validation.BindingResult; +import org.springframework.validation.ObjectError; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import javax.servlet.http.HttpServletRequest; import java.sql.SQLException; +import java.util.Iterator; +import java.util.List; import java.util.Locale; import java.util.ResourceBundle; @@ -78,7 +82,10 @@ public ResponseEntity registerUser( required = true) @RequestBody UserRegisterDto userRegisterDto, - HttpServletRequest request) + @Parameter(hidden = true) + BindingResult bindingResult, + @Parameter(hidden = true) + HttpServletRequest request) throws Exception { Locale locale = languageUtils.parseAcceptLanguage(request.getLocales()); @@ -105,6 +112,22 @@ public ResponseEntity registerUser( } } + // Validate the user registration + if (bindingResult.hasErrors()) { + List errorList = bindingResult.getAllErrors(); + + StringBuilder sb = new StringBuilder(); + Iterator it = errorList.iterator(); + while (it.hasNext()) { + sb.append(messages.getString(it.next().getDefaultMessage())); + if (it.hasNext()) { + sb.append(", "); + } + } + + return new ResponseEntity<>(sb.toString(), HttpStatus.PRECONDITION_FAILED); + } + final UserRepository userRepository = context.getBean(UserRepository.class); if (userRepository.findOneByEmail(userRegisterDto.getEmail()) != null) { return new ResponseEntity<>(String.format( diff --git a/services/src/main/java/org/fao/geonet/api/users/UsersApi.java b/services/src/main/java/org/fao/geonet/api/users/UsersApi.java index 79ce2a88cd0..4c47bd45e00 100644 --- a/services/src/main/java/org/fao/geonet/api/users/UsersApi.java +++ b/services/src/main/java/org/fao/geonet/api/users/UsersApi.java @@ -33,7 +33,11 @@ import org.fao.geonet.ApplicationContextHolder; import org.fao.geonet.api.ApiParams; import org.fao.geonet.api.ApiUtils; +import org.fao.geonet.api.tools.i18n.LanguageUtils; +import org.fao.geonet.api.users.model.PasswordResetDto; import org.fao.geonet.api.users.model.UserDto; +import org.fao.geonet.api.users.validation.PasswordResetDtoValidator; +import org.fao.geonet.api.users.validation.UserDtoValidator; import org.fao.geonet.constants.Params; import org.fao.geonet.domain.*; import org.fao.geonet.exceptions.UserNotFoundEx; @@ -53,6 +57,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Controller; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import javax.imageio.ImageIO; @@ -98,6 +103,9 @@ public class UsersApi { @Autowired DataManager dataManager; + @Autowired + LanguageUtils languageUtils; + private BufferedImage pixel; public UsersApi() { @@ -386,6 +394,8 @@ public ResponseEntity createUser( ) @RequestBody UserDto userDto, + @Parameter(hidden = true) + BindingResult bindingResult, @Parameter(hidden = true) ServletRequest request, @Parameter(hidden = true) @@ -395,6 +405,9 @@ public ResponseEntity createUser( UserSession session = ApiUtils.getUserSession(httpSession); Profile myProfile = session.getProfile(); + Locale locale = languageUtils.parseAcceptLanguage(request.getLocales()); + ResourceBundle messages = ResourceBundle.getBundle("org.fao.geonet.api.Messages", locale); + if (profile == Profile.Administrator) { checkIfAtLeastOneAdminIsEnabled(userDto, userRepository); } @@ -407,10 +420,12 @@ public ResponseEntity createUser( + " max profile permitted is: " + myProfile); } - if (StringUtils.isEmpty(userDto.getUsername())) { - throw new IllegalArgumentException(Params.USERNAME - + " is a required parameter for " - + Params.Operation.NEWUSER + " " + "operation"); + // Validate userDto data + UserDtoValidator userValidator = new UserDtoValidator(); + userValidator.validate(userDto, bindingResult); + String errorMessage = ApiUtils.processRequestValidation(bindingResult, messages); + if (StringUtils.isNotEmpty(errorMessage)) { + throw new IllegalArgumentException(errorMessage); } List existingUsers = userRepository.findByUsernameIgnoreCase(userDto.getUsername()); @@ -554,26 +569,26 @@ public ResponseEntity resetUserPassword( ) @PathVariable Integer userIdentifier, - @Parameter( - description = "Password to change (old)." - ) - @RequestParam(value = Params.PASSWORD + "Old") String passwordOld, - @Parameter( - description = "Password to change." - ) - @RequestParam(value = Params.PASSWORD) String password, - @Parameter( - description = "Password to change (repeat)." - ) - @RequestParam(value = Params.PASSWORD + "2") String password2, + @RequestBody + PasswordResetDto passwordResetDto, + @Parameter(hidden = true) + BindingResult bindingResult, @Parameter(hidden = true) ServletRequest request, @Parameter(hidden = true) HttpSession httpSession ) throws Exception { - if (!password.equals(password2)) { - throw new IllegalArgumentException("Passwords should be equal"); + Locale locale = languageUtils.parseAcceptLanguage(request.getLocales()); + ResourceBundle messages = ResourceBundle.getBundle("org.fao.geonet.api.Messages", locale); + + + // Validate passwordResetDto data + PasswordResetDtoValidator passwordResetValidator = new PasswordResetDtoValidator(); + passwordResetValidator.validate(passwordResetDto, bindingResult); + String errorMessage = ApiUtils.processRequestValidation(bindingResult, messages); + if (StringUtils.isNotEmpty(errorMessage)) { + throw new IllegalArgumentException(errorMessage); } UserSession session = ApiUtils.getUserSession(httpSession); @@ -591,12 +606,12 @@ public ResponseEntity resetUserPassword( PasswordEncoder encoder = PasswordUtil.encoder(ApplicationContextHolder.get()); - if (!encoder.matches(passwordOld, user.get().getPassword())) { + if (!encoder.matches(passwordResetDto.getPasswordOld(), user.get().getPassword())) { throw new IllegalArgumentException("The old password is not valid"); } String passwordHash = PasswordUtil.encoder(ApplicationContextHolder.get()).encode( - password); + passwordResetDto.getPassword()); user.get().getSecurity().setPassword(passwordHash); user.get().getSecurity().getSecurityNotifications().remove(UserSecurityNotification.UPDATE_HASH_REQUIRED); userRepository.save(user.get()); diff --git a/services/src/main/java/org/fao/geonet/api/users/model/PasswordResetDto.java b/services/src/main/java/org/fao/geonet/api/users/model/PasswordResetDto.java new file mode 100644 index 00000000000..7b1bce02edb --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/users/model/PasswordResetDto.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2001-2021 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.users.model; + +import java.util.Objects; + +/** + * DTO class for password reset information. + * + */ +public class PasswordResetDto { + private String passwordOld; + private String password; + private String password2; + + public String getPasswordOld() { + return passwordOld; + } + + public void setPasswordOld(String passwordOld) { + this.passwordOld = passwordOld; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPassword2() { + return password2; + } + + public void setPassword2(String password2) { + this.password2 = password2; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PasswordResetDto that = (PasswordResetDto) o; + return Objects.equals(passwordOld, that.passwordOld) && + Objects.equals(password, that.password) && + Objects.equals(password2, that.password2); + } + + @Override + public int hashCode() { + + return Objects.hash(passwordOld, password, password2); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/users/validation/PasswordResetDtoValidator.java b/services/src/main/java/org/fao/geonet/api/users/validation/PasswordResetDtoValidator.java new file mode 100644 index 00000000000..5e9e9c31dfa --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/users/validation/PasswordResetDtoValidator.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2001-2021 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.users.validation; + +import org.fao.geonet.api.users.model.PasswordResetDto; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +/** + * Validator for PasswordResetDto. + * + */ +public class PasswordResetDtoValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return PasswordResetDto.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + PasswordResetDto passwordReset = (PasswordResetDto) target; + + PasswordValidationUtils.rejectIfInvalid(errors, passwordReset.getPassword()); + + if (!passwordReset.getPassword().equals(passwordReset.getPassword2())) { + + errors.rejectValue("password", "match", + new Object[]{}, + "Passwords should be equal"); + } + } +} diff --git a/services/src/main/java/org/fao/geonet/api/users/validation/PasswordUpdateParameterValidator.java b/services/src/main/java/org/fao/geonet/api/users/validation/PasswordUpdateParameterValidator.java new file mode 100644 index 00000000000..607f544092f --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/users/validation/PasswordUpdateParameterValidator.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2001-2021 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.users.validation; + +import org.fao.geonet.api.users.PasswordUpdateParameter; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + + +/** + * Validator for PasswordUpdateParameter. + * + */ +public class PasswordUpdateParameterValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return PasswordUpdateParameter.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + PasswordUpdateParameter passwordUpdate = (PasswordUpdateParameter) target; + + PasswordValidationUtils.rejectIfInvalid(errors, passwordUpdate.getPassword()); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/users/validation/PasswordValidationUtils.java b/services/src/main/java/org/fao/geonet/api/users/validation/PasswordValidationUtils.java new file mode 100644 index 00000000000..2657308c88d --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/users/validation/PasswordValidationUtils.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2001-2021 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.users.validation; + +import org.apache.commons.lang.StringUtils; +import org.fao.geonet.ApplicationContextHolder; +import org.fao.geonet.kernel.setting.SettingManager; +import org.fao.geonet.kernel.setting.Settings; +import org.springframework.validation.Errors; + +import java.util.regex.Pattern; + +/** + * Utility class for password validation. + * + */ +public class PasswordValidationUtils { + public static void rejectIfInvalid(Errors errors, String password) { + + SettingManager sm =ApplicationContextHolder.get().getBean(SettingManager.class); + Integer minLength = sm.getValueAsInt(Settings.SYSTEM_SECURITY_PASSWORDENFORCEMENT_MINLENGH); + Integer maxLength = sm.getValueAsInt(Settings.SYSTEM_SECURITY_PASSWORDENFORCEMENT_MAXLENGH); + boolean usePattern = sm.getValueAsBool(Settings.SYSTEM_SECURITY_PASSWORDENFORCEMENT_USEPATTERN); + String pattern = sm.getValue(Settings.SYSTEM_SECURITY_PASSWORDENFORCEMENT_PATTERN); + + if (StringUtils.isEmpty(password) || + password.trim().length() < minLength) { + errors.rejectValue("password", "field.length", + new Object[]{Integer.valueOf(minLength), Integer.valueOf(maxLength)}, + "Password size should be between " + minLength + " and " + maxLength + " characters"); + } + + if (StringUtils.isNotEmpty(password) && + password.trim().length() > maxLength) { + errors.rejectValue("password", "field.length", + new Object[]{Integer.valueOf(minLength), Integer.valueOf(maxLength)}, + "Password size should be between " + minLength + " and " + maxLength + " characters"); + } + + if (usePattern && !Pattern.matches(pattern, password)) { + errors.rejectValue("password", "field.invalid", + new Object[]{Integer.valueOf(maxLength)}, + "Password must contain at least 1 uppercase, 1 lowercase, 1 number and 1 symbol. Symbols include: `~!@#$%^&*()-_=+[]{}\\\\|;:'\",.<>/?');"); + } + } +} diff --git a/services/src/main/java/org/fao/geonet/api/users/validation/UserDtoValidator.java b/services/src/main/java/org/fao/geonet/api/users/validation/UserDtoValidator.java new file mode 100644 index 00000000000..509c1effff5 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/users/validation/UserDtoValidator.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2001-2021 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.users.validation; + +import org.fao.geonet.ApplicationContextHolder; +import org.fao.geonet.api.users.model.UserDto; +import org.fao.geonet.constants.Params; +import org.fao.geonet.domain.User; +import org.fao.geonet.repository.UserRepository; +import org.springframework.validation.Errors; +import org.springframework.validation.ValidationUtils; +import org.springframework.validation.Validator; + +import java.util.List; + +/** + * Validator for UserDto. + * + */ +public class UserDtoValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return UserDto.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + UserDto user = (UserDto) target; + + ValidationUtils.rejectIfEmptyOrWhitespace(errors, "username", "field.required", Params.USERNAME + + " is a required parameter for " + + Params.Operation.NEWUSER + " " + "operation"); + + UserRepository userRepository = ApplicationContextHolder.get().getBean(UserRepository.class); + + List existingUsers = userRepository.findByUsernameIgnoreCase(user.getUsername()); + if (!existingUsers.isEmpty()) { + errors.rejectValue("username", "duplicated", + new Object[]{user.getUsername()}, + "Users with username " + + user.getUsername() + " ignore case already exists"); + } + + PasswordValidationUtils.rejectIfInvalid(errors, user.getPassword()); + } +} diff --git a/services/src/test/java/org/fao/geonet/api/users/UsersApiTest.java b/services/src/test/java/org/fao/geonet/api/users/UsersApiTest.java index 0f166d58e16..aec1802a7d2 100644 --- a/services/src/test/java/org/fao/geonet/api/users/UsersApiTest.java +++ b/services/src/test/java/org/fao/geonet/api/users/UsersApiTest.java @@ -26,6 +26,7 @@ import com.google.gson.Gson; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang.StringUtils; +import org.fao.geonet.api.users.model.PasswordResetDto; import org.fao.geonet.api.users.model.UserDto; import org.fao.geonet.domain.Group; import org.fao.geonet.domain.Profile; @@ -225,7 +226,7 @@ public void createUser() throws Exception { user.setProfile(Profile.Editor.name()); user.setGroupsEditor(Collections.singletonList("2")); user.setEmail(Collections.singletonList("mail@test.com")); - user.setPassword("password"); + user.setPassword("Password7$"); user.setEnabled(true); Gson gson = new Gson(); @@ -253,7 +254,7 @@ public void createUserMissingUsername() throws Exception { user.setProfile(Profile.Editor.name()); user.setGroupsEditor(Collections.singletonList("2")); user.setEmail(Collections.singletonList("mail@test.com")); - user.setPassword("password"); + user.setPassword("Password1$"); user.setEnabled(true); Gson gson = new Gson(); @@ -281,7 +282,7 @@ public void createDuplicatedUsername() throws Exception { user.setProfile(Profile.Editor.name()); user.setGroupsEditor(Collections.singletonList("2")); user.setEmail(Collections.singletonList("mail@test.com")); - user.setPassword("password"); + user.setPassword("Password1$"); user.setEnabled(true); Gson gson = new Gson(); @@ -311,7 +312,7 @@ public void createDuplicatedUsernameIgnoreCase() throws Exception { user.setProfile(Profile.Editor.name()); user.setGroupsEditor(Collections.singletonList("2")); user.setEmail(Collections.singletonList("mail@test.com")); - user.setPassword("password"); + user.setPassword("Password1$"); user.setEnabled(true); Gson gson = new Gson(); @@ -342,10 +343,16 @@ public void resetPassword() throws Exception { this.mockHttpSession = loginAsAdmin(); + Gson gson = new Gson(); + PasswordResetDto passwordReset = new PasswordResetDto(); + passwordReset.setPasswordOld("testuser-editor-password"); + passwordReset.setPassword("NewPassword1$"); + passwordReset.setPassword2("NewPassword1$"); + + String json = gson.toJson(passwordReset); + this.mockMvc.perform(post("/srv/api/users/" + user.getId() + "/actions/forget-password") - .param("passwordOld", "testuser-editor-password") - .param("password", "newpassword") - .param("password2", "newpassword") + .content(json) .contentType(API_JSON_EXPECTED_ENCODING) .session(this.mockHttpSession) .accept(MediaType.parseMediaType("application/json"))) @@ -364,11 +371,17 @@ public void resetPasswordToNotAllowedUser() throws Exception { Assert.assertTrue(user.getProfile().equals(Profile.Editor)); this.mockHttpSession = loginAs(user); + Gson gson = new Gson(); + PasswordResetDto passwordReset = new PasswordResetDto(); + passwordReset.setPasswordOld("testuser-editor-password"); + passwordReset.setPassword("NewPassword1$"); + passwordReset.setPassword2("NewPassword1$"); + + String json = gson.toJson(passwordReset); + // Try to update the password of admin user from a user with Editor profile this.mockMvc.perform(post("/srv/api/users/" + admin.getId() + "/actions/forget-password") - .param("passwordOld", "testuser-editor-password") - .param("password", "newpassword") - .param("password2", "newpassword") + .content(json) .contentType(API_JSON_EXPECTED_ENCODING) .session(this.mockHttpSession) .accept(MediaType.parseMediaType("application/json"))) @@ -385,12 +398,18 @@ public void resetPasswordNotEqual() throws Exception { this.mockHttpSession = loginAsAdmin(); + Gson gson = new Gson(); + PasswordResetDto passwordReset = new PasswordResetDto(); + passwordReset.setPasswordOld("testuser-editor-password"); + passwordReset.setPassword("NewPassword1$"); + passwordReset.setPassword2("NewPassword2%"); + + String json = gson.toJson(passwordReset); + // Check 400 is returned and a message indicating that passwords should be equal this.mockMvc.perform(post("/srv/api/users/" + user.getId() + "/actions/forget-password") .contentType(API_JSON_EXPECTED_ENCODING) - .param("passwordOld", "testuser-editor-password") - .param("password", "newpassword") - .param("password2", "newpassword2") + .content(json) .session(this.mockHttpSession) .accept(MediaType.parseMediaType("application/json"))) .andExpect(jsonPath("$.description", is("Passwords should be equal"))) @@ -406,12 +425,18 @@ public void resetPasswordWrongOldPassword() throws Exception { this.mockHttpSession = loginAsAdmin(); + Gson gson = new Gson(); + PasswordResetDto passwordReset = new PasswordResetDto(); + passwordReset.setPasswordOld("testuser-editor-password-wrong"); + passwordReset.setPassword("NewPassword1$"); + passwordReset.setPassword2("NewPassword1$"); + + String json = gson.toJson(passwordReset); + // Check 400 is returned and a message indicating that passwords should be equal this.mockMvc.perform(post("/srv/api/users/" + user.getId() + "/actions/forget-password") .contentType(API_JSON_EXPECTED_ENCODING) - .param("passwordOld", "testuser-editor-password-wrong") - .param("password", "newpassword") - .param("password2", "newpassword") + .content(json) .session(this.mockHttpSession) .accept(MediaType.parseMediaType("application/json"))) .andExpect(jsonPath("$.description", is("The old password is not valid"))) @@ -426,12 +451,18 @@ public void resetPasswordNotExistingUser() throws Exception { this.mockHttpSession = loginAsAdmin(); + Gson gson = new Gson(); + PasswordResetDto passwordReset = new PasswordResetDto(); + passwordReset.setPasswordOld("oldpassword"); + passwordReset.setPassword("NewPassword1$"); + passwordReset.setPassword2("NewPassword1$"); + + String json = gson.toJson(passwordReset); + // Check 404 is returned this.mockMvc.perform(post("/srv/api/users/" + userId + "/actions/forget-password") .contentType(API_JSON_EXPECTED_ENCODING) - .param("passwordOld", "oldpassword") - .param("password", "newpassword") - .param("password2", "newpassword") + .content(json) .session(this.mockHttpSession) .accept(MediaType.parseMediaType("application/json"))) .andExpect(jsonPath("$.description", is("User not found"))) @@ -447,11 +478,17 @@ public void resetPasswordSameUser() throws Exception { Assert.assertTrue(user.getProfile().equals(Profile.Editor)); this.mockHttpSession = loginAs(user); + Gson gson = new Gson(); + PasswordResetDto passwordReset = new PasswordResetDto(); + passwordReset.setPasswordOld("testuser-editor-password"); + passwordReset.setPassword("NewPassword1$"); + passwordReset.setPassword2("NewPassword1$"); + + String json = gson.toJson(passwordReset); + this.mockMvc.perform(post("/srv/api/users/" + user.getId() + "/actions/forget-password") .contentType(API_JSON_EXPECTED_ENCODING) - .param("passwordOld", "testuser-editor-password") - .param("password", "newpassword") - .param("password2", "newpassword") + .content(json) .session(this.mockHttpSession) .accept(MediaType.parseMediaType("application/json"))) .andExpect(status().is(204)); diff --git a/web-ui/src/main/resources/catalog/js/LoginController.js b/web-ui/src/main/resources/catalog/js/LoginController.js index 2c02272b115..373fd4bfc89 100644 --- a/web-ui/src/main/resources/catalog/js/LoginController.js +++ b/web-ui/src/main/resources/catalog/js/LoginController.js @@ -68,6 +68,13 @@ $scope.isDisableLoginForm = gnGlobalSettings.isDisableLoginForm; $scope.isShowLoginAsLink = gnGlobalSettings.isShowLoginAsLink; + $scope.passwordMinLength = + Math.min(gnConfig['system.security.passwordEnforcement.minLength'], 6); + $scope.passwordMaxLength = + Math.max(gnConfig['system.security.passwordEnforcement.maxLength'], 6); + $scope.passwordPattern = + gnConfig['system.security.passwordEnforcement.pattern']; + function initForm() { if ($window.location.pathname.indexOf('new.password') !== -1) { // Retrieve username from URL parameter diff --git a/web-ui/src/main/resources/catalog/js/admin/SystemSettingsController.js b/web-ui/src/main/resources/catalog/js/admin/SystemSettingsController.js index f59a047acbc..c60c37b4f1c 100644 --- a/web-ui/src/main/resources/catalog/js/admin/SystemSettingsController.js +++ b/web-ui/src/main/resources/catalog/js/admin/SystemSettingsController.js @@ -50,7 +50,8 @@ module.filter('hideGeoNetworkInternalSettings', function() { return function(input) { var filtered = []; - var internal = ['system/userFeedback/lastNotificationDate']; + var internal = ['system/userFeedback/lastNotificationDate', + 'system/security/passwordEnforcement/pattern']; angular.forEach(input, function(el) { if (internal.indexOf(el.name) === -1) { filtered.push(el); diff --git a/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js b/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js index 8f175d9bbb2..1ff095e0792 100644 --- a/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js +++ b/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js @@ -41,9 +41,9 @@ */ module.controller('GnUserGroupController', [ '$scope', '$routeParams', '$http', '$rootScope', - '$translate', '$timeout', + '$translate', '$timeout', 'gnConfig', function($scope, $routeParams, $http, $rootScope, - $translate, $timeout) { + $translate, $timeout, gnConfig) { $scope.searchObj = { params: { @@ -102,6 +102,16 @@ $scope.isLoadingUsers = false; $scope.isLoadingGroups = false; + $scope.passwordMinLength = + Math.min(gnConfig['system.security.passwordEnforcement.minLength'], 6); + $scope.passwordMaxLength = + Math.max(gnConfig['system.security.passwordEnforcement.maxLength'], 6); + $scope.usePattern = gnConfig['system.security.passwordEnforcement.usePattern']; + + if ($scope.usePattern) { + $scope.passwordPattern = new RegExp( + gnConfig['system.security.passwordEnforcement.pattern']); + } // This is to force IE11 NOT to cache json requests if (!$http.defaults.headers.get) { @@ -307,10 +317,7 @@ $http.post('../api/users/' + $scope.userSelected.id + '/actions/forget-password', - $.param(params), - { - headers: {'Content-Type': 'application/x-www-form-urlencoded'} - }) + params) .success(function(data) { $scope.resetPassword1 = null; $scope.resetPassword2 = null; @@ -534,12 +541,20 @@ data) .then( function(r) { - $scope.unselectUser(); - loadUsers(); - $rootScope.$broadcast('StatusUpdated', { - msg: $translate.instant('userUpdated'), - timeout: 2, - type: 'success'}); + if (r.status === 204) { + $scope.unselectUser(); + loadUsers(); + $rootScope.$broadcast('StatusUpdated', { + msg: $translate.instant('userUpdated'), + timeout: 2, + type: 'success'}); + } else { + $rootScope.$broadcast('StatusUpdated', { + title: $translate.instant('userUpdateError'), + error: r.data, + timeout: 0, + type: 'danger'}); + } }, function(r) { $rootScope.$broadcast('StatusUpdated', { diff --git a/web-ui/src/main/resources/catalog/locales/en-admin.json b/web-ui/src/main/resources/catalog/locales/en-admin.json index ee49b113e4f..d61263f5eda 100644 --- a/web-ui/src/main/resources/catalog/locales/en-admin.json +++ b/web-ui/src/main/resources/catalog/locales/en-admin.json @@ -893,7 +893,12 @@ "system/searchStats": "Search statistics", "system/searchStats/enable": "Enable", "system/searchStats/enable-help": "If set, statistics on searches will be collected and stored in the database.", - "system/selectionmanager": "Metadata Search Results", + "system/security": "Security", + "system/security/passwordEnforcement/maxLength": "Password max. length", + "system/security/passwordEnforcement/minLength": "Password min. length", + "system/security/passwordEnforcement/usePattern": "Password restrictions", + "system/security/passwordEnforcement/usePattern-help": "Require that the password must contain at least 1 uppercase, 1 lowercase, 1 number and 1 symbol", + "system/selectionmanager": "Metadata Search Results", "system/selectionmanager/maxrecords": "Maximum selected records", "system/server": "Catalog server", "system/server/host": "Host", @@ -1345,7 +1350,7 @@ "ui-definition": "Definition", "ui-label": "Label", "ui-code": "Code", - "ui-getProj": "Retrieve projection settings from {{value}}", + "ui-getProj": "Retrieve projection settings from {{value}}", "ui-extent-help": "This is the default extent of the map for this projection in projected units (e.g. metre). This is a mandatory field if the projection is not already defined in GeoNetwork.", "ui-worldExtent-help": "This is the maximum world extent of the map for this projection in degrees (longitude-latitude). This is a mandatory field if the projection is not already defined in GeoNetwork.", "ui-label-help": "Label to show on the user interface to identify this projection. This is a mandatory field.", diff --git a/web-ui/src/main/resources/catalog/locales/en-core.json b/web-ui/src/main/resources/catalog/locales/en-core.json index d5a5c7348fb..ae2fdeaebdf 100644 --- a/web-ui/src/main/resources/catalog/locales/en-core.json +++ b/web-ui/src/main/resources/catalog/locales/en-core.json @@ -214,8 +214,10 @@ "privilegesUpdatedError": "Error occurred while updating privileges.", "publisher": "Publisher", "password": "Password", - "passwordMinlength": "Password must contain at least 6 characters!", + "passwordMinlength": "Password must contain at least {{length}} characters!", "passwordNotMatching": "The password does not match!", + "passwordMaxlength": "Password must contain at most {{length}} characters!", + "passwordPattern": "Password must contain at least 1 uppercase, 1 lowercase, 1 number and 1 symbol. Symbols include: `~!@#$%^&*()-_=+[]{}\\|;:'\",.<>/?');", "passwordOld": "Old password", "passwordRepeat": "Repeat password", "groupNameMaxlength": "Group name can not exceed 32 characters!", diff --git a/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html b/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html index 34cd3b79c0d..f4a4fd39505 100644 --- a/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html +++ b/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html @@ -113,19 +113,28 @@

- passwordMinlength +

+ +
+ +
passwordPattern

@@ -359,11 +368,20 @@

UserAdmin

+ data-ng-minlength="{{passwordMinLength}}" + data-ng-maxlength="{{passwordMaxLength}}" + data-ng-required="true" + data-ng-model="resetPassword1" + data-ng-pattern="passwordPattern" />

- passwordMinlength +

+ +
+ +
passwordPattern

diff --git a/web-ui/src/main/resources/catalog/templates/new-password.html b/web-ui/src/main/resources/catalog/templates/new-password.html index 9e7fe9f8b9d..81bc40def54 100644 --- a/web-ui/src/main/resources/catalog/templates/new-password.html +++ b/web-ui/src/main/resources/catalog/templates/new-password.html @@ -33,11 +33,19 @@

updatePassword

autocomplete="off" required="required" aria-label="{{'password' | translate}}" - data-ng-minlength="6" - data-ng-model="password"/> + data-ng-minlength="{{passwordMinLength}}" + data-ng-maxlength="{{passwordMaxLength}}" + data-ng-model="password" + data-ng-pattern="passwordPattern"/>

- passwordMinlength +

+ +
+ +
passwordPattern