Browse Source

Merge pull request #9775 from dashevchenko/passwordValidation

Added max length password policy
pull/9818/head
Andrew Shvayka 3 years ago
committed by GitHub
parent
commit
2a3960ff64
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 33
      application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsViolationResponse.java
  2. 4
      application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java
  3. 26
      application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordNotValidException.java
  4. 55
      application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
  5. 83
      application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
  6. 3
      common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java
  7. 4
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/UserPasswordPolicy.java

33
application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsViolationResponse.java

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.exception;
import io.swagger.annotations.ApiModel;
import org.springframework.http.HttpStatus;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
@ApiModel
public class ThingsboardCredentialsViolationResponse extends ThingsboardErrorResponse {
protected ThingsboardCredentialsViolationResponse(String message) {
super(message, ThingsboardErrorCode.PASSWORD_VIOLATION, HttpStatus.UNAUTHORIZED);
}
public static ThingsboardCredentialsViolationResponse of(final String message) {
return new ThingsboardCredentialsViolationResponse(message);
}
}

4
application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java

@ -41,6 +41,7 @@ import org.thingsboard.server.common.msg.tools.TbRateLimitsException;
import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException;
import org.thingsboard.server.service.security.exception.JwtExpiredTokenException;
import org.thingsboard.server.service.security.exception.UserPasswordExpiredException;
import org.thingsboard.server.service.security.exception.UserPasswordNotValidException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
@ -191,6 +192,9 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
UserPasswordExpiredException expiredException = (UserPasswordExpiredException) authenticationException;
String resetToken = expiredException.getResetToken();
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken));
} else if (authenticationException instanceof UserPasswordNotValidException) {
UserPasswordNotValidException expiredException = (UserPasswordNotValidException) authenticationException;
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(expiredException.getMessage()));
} else {
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
}

26
application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordNotValidException.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.exception;
import org.springframework.security.core.AuthenticationException;
public class UserPasswordNotValidException extends AuthenticationException {
public UserPasswordNotValidException(String msg) {
super(msg);
}
}

55
application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java

@ -57,6 +57,7 @@ import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.user.UserServiceImpl;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
import org.thingsboard.server.service.security.exception.UserPasswordExpiredException;
import org.thingsboard.server.service.security.exception.UserPasswordNotValidException;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.utils.MiscUtils;
import ua_parser.Client;
@ -107,6 +108,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
securitySettings = new SecuritySettings();
securitySettings.setPasswordPolicy(new UserPasswordPolicy());
securitySettings.getPasswordPolicy().setMinimumLength(6);
securitySettings.getPasswordPolicy().setMaximumLength(72);
}
return securitySettings;
}
@ -131,9 +133,19 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
@Override
public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException {
SecuritySettings securitySettings = self.getSecuritySettings(tenantId);
UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy();
if (!tenantId.isSysTenantId() && Boolean.TRUE.equals(passwordPolicy.getForceUserToResetPasswordIfNotValid())) {
try {
validatePasswordByPolicy(password, passwordPolicy);
} catch (DataValidationException e) {
throw new UserPasswordNotValidException("The entered password violates our policies. If this is your real password, please reset it.");
}
}
if (!encoder.matches(password, userCredentials.getPassword())) {
int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = self.getSecuritySettings(tenantId);
if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) {
if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) {
lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts());
@ -149,7 +161,6 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = self.getSecuritySettings(tenantId);
if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) {
if ((userCredentials.getCreatedTime()
+ TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays()))
@ -199,8 +210,31 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
SecuritySettings securitySettings = self.getSecuritySettings(tenantId);
UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy();
validatePasswordByPolicy(password, passwordPolicy);
if (userCredentials != null && isPositiveInteger(passwordPolicy.getPasswordReuseFrequencyDays())) {
long passwordReuseFrequencyTs = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(passwordPolicy.getPasswordReuseFrequencyDays());
JsonNode additionalInfo = userCredentials.getAdditionalInfo();
if (additionalInfo instanceof ObjectNode && additionalInfo.has(UserServiceImpl.USER_PASSWORD_HISTORY)) {
JsonNode userPasswordHistoryJson = additionalInfo.get(UserServiceImpl.USER_PASSWORD_HISTORY);
Map<String, String> userPasswordHistoryMap = JacksonUtil.convertValue(userPasswordHistoryJson, new TypeReference<>() {});
for (Map.Entry<String, String> entry : userPasswordHistoryMap.entrySet()) {
if (encoder.matches(password, entry.getValue()) && Long.parseLong(entry.getKey()) > passwordReuseFrequencyTs) {
throw new DataValidationException("Password was already used for the last " + passwordPolicy.getPasswordReuseFrequencyDays() + " days");
}
}
}
}
}
private void validatePasswordByPolicy(String password, UserPasswordPolicy passwordPolicy) {
List<Rule> passwordRules = new ArrayList<>();
passwordRules.add(new LengthRule(passwordPolicy.getMinimumLength(), Integer.MAX_VALUE));
Integer maximumLength = passwordPolicy.getMaximumLength();
Integer minLengthBound = passwordPolicy.getMinimumLength();
int maxLengthBound = (maximumLength != null && maximumLength > passwordPolicy.getMinimumLength()) ? maximumLength : Integer.MAX_VALUE;
passwordRules.add(new LengthRule(minLengthBound, maxLengthBound));
if (isPositiveInteger(passwordPolicy.getMinimumUppercaseLetters())) {
passwordRules.add(new CharacterRule(EnglishCharacterData.UpperCase, passwordPolicy.getMinimumUppercaseLetters()));
}
@ -223,21 +257,6 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
String message = String.join("\n", validator.getMessages(result));
throw new DataValidationException(message);
}
if (userCredentials != null && isPositiveInteger(passwordPolicy.getPasswordReuseFrequencyDays())) {
long passwordReuseFrequencyTs = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(passwordPolicy.getPasswordReuseFrequencyDays());
JsonNode additionalInfo = userCredentials.getAdditionalInfo();
if (additionalInfo instanceof ObjectNode && additionalInfo.has(UserServiceImpl.USER_PASSWORD_HISTORY)) {
JsonNode userPasswordHistoryJson = additionalInfo.get(UserServiceImpl.USER_PASSWORD_HISTORY);
Map<String, String> userPasswordHistoryMap = JacksonUtil.convertValue(userPasswordHistoryJson, new TypeReference<>() {});
for (Map.Entry<String, String> entry : userPasswordHistoryMap.entrySet()) {
if (encoder.matches(password, entry.getValue()) && Long.parseLong(entry.getKey()) > passwordReuseFrequencyTs) {
throw new DataValidationException("Password was already used for the last " + passwordPolicy.getPasswordReuseFrequencyDays() + " days");
}
}
}
}
}
@Override

83
application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java

@ -15,19 +15,41 @@
*/
package org.thingsboard.server.controller;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.After;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.http.HttpHeaders;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
import org.thingsboard.server.service.security.model.ChangePasswordRequest;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
public class AuthControllerTest extends AbstractControllerTest {
@After
public void tearDown() throws Exception {
loginSysAdmin();
SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class);
securitySettings.getPasswordPolicy().setMaximumLength(72);
securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false);
doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk());
}
@Test
public void testGetUser() throws Exception {
@ -84,4 +106,65 @@ public class AuthControllerTest extends AbstractControllerTest {
.andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name())))
.andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL)));
}
@Test
public void testShouldNotUpdatePasswordWithValueLongerThanDefaultLimit() throws Exception {
loginTenantAdmin();
ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest();
changePasswordRequest.setCurrentPassword("tenant");
changePasswordRequest.setNewPassword(RandomStringUtils.randomAlphanumeric(73));
doPost("/api/auth/changePassword", changePasswordRequest)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", is("Password must be no more than 72 characters in length.")));
}
@Test
public void testShouldNotAuthorizeUserIfHisPasswordBecameTooLong() throws Exception {
loginTenantAdmin();
ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest();
changePasswordRequest.setCurrentPassword("tenant");
String newPassword = RandomStringUtils.randomAlphanumeric(16);
changePasswordRequest.setNewPassword(newPassword);
doPost("/api/auth/changePassword", changePasswordRequest)
.andExpect(status().isOk());
loginUser(TENANT_ADMIN_EMAIL, newPassword);
loginSysAdmin();
SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class);
securitySettings.getPasswordPolicy().setMaximumLength(15);
securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true);
doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk());
//try to login with user password that is not valid after security settings was updated
doPost("/api/auth/login", new LoginRequest(TENANT_ADMIN_EMAIL, newPassword))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message", is("The entered password violates our policies. If this is your real password, please reset it.")));
}
@Test
public void testShouldNotResetPasswordToTooLongValue() throws Exception {
loginTenantAdmin();
JsonNode resetPasswordByEmailRequest = JacksonUtil.newObjectNode()
.put("email", TENANT_ADMIN_EMAIL);
doPost("/api/noauth/resetPasswordByEmail", resetPasswordByEmailRequest)
.andExpect(status().isOk());
Thread.sleep(1000);
doGet("/api/noauth/resetPassword?resetToken={resetToken}", this.currentResetPasswordToken)
.andExpect(status().isSeeOther())
.andExpect(header().string(HttpHeaders.LOCATION, "/login/resetPassword?resetToken=" + this.currentResetPasswordToken));
String newPassword = RandomStringUtils.randomAlphanumeric(73);
JsonNode resetPasswordRequest = JacksonUtil.newObjectNode()
.put("resetToken", this.currentResetPasswordToken)
.put("password", newPassword);
Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString());
doPost("/api/noauth/resetPassword", resetPasswordRequest)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message",
is("Password must be no more than 72 characters in length.")));
}
}

3
common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java

@ -29,7 +29,8 @@ public enum ThingsboardErrorCode {
ITEM_NOT_FOUND(32),
TOO_MANY_REQUESTS(33),
TOO_MANY_UPDATES(34),
SUBSCRIPTION_VIOLATION(40);
SUBSCRIPTION_VIOLATION(40),
PASSWORD_VIOLATION(45);
private int errorCode;

4
common/data/src/main/java/org/thingsboard/server/common/data/security/model/UserPasswordPolicy.java

@ -27,6 +27,8 @@ public class UserPasswordPolicy implements Serializable {
@ApiModelProperty(position = 1, value = "Minimum number of symbols in the password." )
private Integer minimumLength;
@ApiModelProperty(position = 1, value = "Maximum number of symbols in the password." )
private Integer maximumLength;
@ApiModelProperty(position = 1, value = "Minimum number of uppercase letters in the password." )
private Integer minimumUppercaseLetters;
@ApiModelProperty(position = 1, value = "Minimum number of lowercase letters in the password." )
@ -37,6 +39,8 @@ public class UserPasswordPolicy implements Serializable {
private Integer minimumSpecialCharacters;
@ApiModelProperty(position = 1, value = "Allow whitespaces")
private Boolean allowWhitespaces = true;
@ApiModelProperty(position = 1, value = "Force user to update password if existing one does not pass validation")
private Boolean forceUserToResetPasswordIfNotValid = false;
@ApiModelProperty(position = 1, value = "Password expiration period (days). Force expiration of the password." )
private Integer passwordExpirationPeriodDays;

Loading…
Cancel
Save