Browse Source

Feature/security updates (#1993)

* Added last login to user details

* Added last login to profile page

* Fixed label style

* Added actionType filter to Audit Logs controller

* Lockout feature; Password Reuse frequency

* Send email notification in case user lockout; Refactoring

* Fixed set of null password history; Added toast on user account locked/unlocked

* Typo fixed

* Ignored json validator for securitySettings

* Added Lockout action type

* Added permission check for user disable/enable

* Fixed email title
pull/2053/head
VoBa 7 years ago
committed by Igor Kulikov
parent
commit
3935f616c7
  1. 38
      application/src/main/java/org/thingsboard/server/controller/AuditLogController.java
  2. 6
      application/src/main/java/org/thingsboard/server/controller/AuthController.java
  3. 22
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  4. 11
      application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java
  5. 15
      application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
  6. 20
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/security/model/SecuritySettings.java
  8. 1
      application/src/main/java/org/thingsboard/server/service/security/model/UserPasswordPolicy.java
  9. 61
      application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java
  11. 1
      application/src/main/resources/i18n/messages.properties
  12. 112
      application/src/main/resources/templates/account.lockout.vm
  13. 8
      common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java
  14. 7
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  15. 3
      common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java
  16. 9
      dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java
  17. 17
      dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java
  18. 9
      dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java
  19. 8
      dao/src/main/java/org/thingsboard/server/dao/audit/DummyAuditLogServiceImpl.java
  20. 4
      dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java
  21. 34
      dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java
  22. 123
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  23. 6
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java
  24. 42
      ui/src/app/admin/security-settings.tpl.html
  25. 17
      ui/src/app/api/user.service.js
  26. 3
      ui/src/app/common/types.constant.js
  27. 16
      ui/src/app/locale/locale.constant-en_US.json
  28. 4
      ui/src/app/profile/profile.tpl.html
  29. 6
      ui/src/app/user/user-fieldset.tpl.html
  30. 17
      ui/src/app/user/user.controller.js
  31. 10
      ui/src/app/user/user.directive.js
  32. 3
      ui/src/app/user/users.tpl.html

38
application/src/main/java/org/thingsboard/server/controller/AuditLogController.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@ -22,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
@ -31,7 +33,10 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.TimePageData;
import org.thingsboard.server.common.data.page.TimePageLink;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api")
@ -46,12 +51,14 @@ public class AuditLogController extends BaseController {
@RequestParam(required = false) Long startTime,
@RequestParam(required = false) Long endTime,
@RequestParam(required = false, defaultValue = "false") boolean ascOrder,
@RequestParam(required = false) String offset) throws ThingsboardException {
@RequestParam(required = false) String offset,
@RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException {
try {
checkParameter("CustomerId", strCustomerId);
TenantId tenantId = getCurrentUser().getTenantId();
TimePageLink pageLink = createPageLink(limit, startTime, endTime, ascOrder, offset);
return checkNotNull(auditLogService.findAuditLogsByTenantIdAndCustomerId(tenantId, new CustomerId(UUID.fromString(strCustomerId)), pageLink));
List<ActionType> actionTypes = parseActionTypesStr(actionTypesStr);
return checkNotNull(auditLogService.findAuditLogsByTenantIdAndCustomerId(tenantId, new CustomerId(UUID.fromString(strCustomerId)), actionTypes, pageLink));
} catch (Exception e) {
throw handleException(e);
}
@ -66,12 +73,14 @@ public class AuditLogController extends BaseController {
@RequestParam(required = false) Long startTime,
@RequestParam(required = false) Long endTime,
@RequestParam(required = false, defaultValue = "false") boolean ascOrder,
@RequestParam(required = false) String offset) throws ThingsboardException {
@RequestParam(required = false) String offset,
@RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException {
try {
checkParameter("UserId", strUserId);
TenantId tenantId = getCurrentUser().getTenantId();
TimePageLink pageLink = createPageLink(limit, startTime, endTime, ascOrder, offset);
return checkNotNull(auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, new UserId(UUID.fromString(strUserId)), pageLink));
List<ActionType> actionTypes = parseActionTypesStr(actionTypesStr);
return checkNotNull(auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, new UserId(UUID.fromString(strUserId)), actionTypes, pageLink));
} catch (Exception e) {
throw handleException(e);
}
@ -87,13 +96,15 @@ public class AuditLogController extends BaseController {
@RequestParam(required = false) Long startTime,
@RequestParam(required = false) Long endTime,
@RequestParam(required = false, defaultValue = "false") boolean ascOrder,
@RequestParam(required = false) String offset) throws ThingsboardException {
@RequestParam(required = false) String offset,
@RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException {
try {
checkParameter("EntityId", strEntityId);
checkParameter("EntityType", strEntityType);
TenantId tenantId = getCurrentUser().getTenantId();
TimePageLink pageLink = createPageLink(limit, startTime, endTime, ascOrder, offset);
return checkNotNull(auditLogService.findAuditLogsByTenantIdAndEntityId(tenantId, EntityIdFactory.getByTypeAndId(strEntityType, strEntityId), pageLink));
List<ActionType> actionTypes = parseActionTypesStr(actionTypesStr);
return checkNotNull(auditLogService.findAuditLogsByTenantIdAndEntityId(tenantId, EntityIdFactory.getByTypeAndId(strEntityType, strEntityId), actionTypes, pageLink));
} catch (Exception e) {
throw handleException(e);
}
@ -107,13 +118,24 @@ public class AuditLogController extends BaseController {
@RequestParam(required = false) Long startTime,
@RequestParam(required = false) Long endTime,
@RequestParam(required = false, defaultValue = "false") boolean ascOrder,
@RequestParam(required = false) String offset) throws ThingsboardException {
@RequestParam(required = false) String offset,
@RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException {
try {
TenantId tenantId = getCurrentUser().getTenantId();
TimePageLink pageLink = createPageLink(limit, startTime, endTime, ascOrder, offset);
return checkNotNull(auditLogService.findAuditLogsByTenantId(tenantId, pageLink));
List<ActionType> actionTypes = parseActionTypesStr(actionTypesStr);
return checkNotNull(auditLogService.findAuditLogsByTenantId(tenantId, actionTypes, pageLink));
} catch (Exception e) {
throw handleException(e);
}
}
private List<ActionType> parseActionTypesStr(String actionTypesStr) {
List<ActionType> result = null;
if (StringUtils.isNoneBlank(actionTypesStr)) {
String[] tmp = actionTypesStr.split(",");
result = Arrays.stream(tmp).map(at -> ActionType.valueOf(at.toUpperCase())).collect(Collectors.toList());
}
return result;
}
}

6
application/src/main/java/org/thingsboard/server/controller/AuthController.java

@ -112,7 +112,7 @@ public class AuthController extends BaseController {
if (!passwordEncoder.matches(currentPassword, userCredentials.getPassword())) {
throw new ThingsboardException("Current password doesn't match!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
systemSecurityService.validatePassword(securityUser.getTenantId(), newPassword);
systemSecurityService.validatePassword(securityUser.getTenantId(), newPassword, userCredentials);
if (passwordEncoder.matches(newPassword, userCredentials.getPassword())) {
throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
@ -206,7 +206,7 @@ public class AuthController extends BaseController {
try {
String activateToken = activateRequest.get("activateToken").asText();
String password = activateRequest.get("password").asText();
systemSecurityService.validatePassword(TenantId.SYS_TENANT_ID, password);
systemSecurityService.validatePassword(TenantId.SYS_TENANT_ID, password, null);
String encodedPassword = passwordEncoder.encode(password);
UserCredentials credentials = userService.activateUserCredentials(TenantId.SYS_TENANT_ID, activateToken, encodedPassword);
User user = userService.findUserById(TenantId.SYS_TENANT_ID, credentials.getUserId());
@ -246,7 +246,7 @@ public class AuthController extends BaseController {
String password = resetPasswordRequest.get("password").asText();
UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken);
if (userCredentials != null) {
systemSecurityService.validatePassword(TenantId.SYS_TENANT_ID, password);
systemSecurityService.validatePassword(TenantId.SYS_TENANT_ID, password, userCredentials);
if (passwordEncoder.matches(password, userCredentials.getPassword())) {
throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}

22
application/src/main/java/org/thingsboard/server/controller/UserController.java

@ -126,7 +126,7 @@ public class UserController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/user", method = RequestMethod.POST)
@ResponseBody
@ResponseBody
public User saveUser(@RequestBody User user,
@RequestParam(required = false, defaultValue = "true") boolean sendActivationMail,
HttpServletRequest request) throws ThingsboardException {
@ -285,5 +285,23 @@ public class UserController extends BaseController {
throw handleException(e);
}
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@RequestMapping(value = "/user/{userId}/userCredentialsEnabled", method = RequestMethod.POST)
@ResponseBody
public void setUserCredentialsEnabled(@PathVariable(USER_ID) String strUserId,
@RequestParam(required = false, defaultValue = "true") boolean userCredentialsEnabled) throws ThingsboardException {
checkParameter(USER_ID, strUserId);
try {
UserId userId = new UserId(toUUID(strUserId));
User user = checkUserId(userId, Operation.WRITE);
accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.WRITE, user.getId(), user);
TenantId tenantId = getCurrentUser().getTenantId();
userService.setUserCredentialsEnabled(tenantId, userId, userCredentialsEnabled);
} catch (Exception e) {
throw handleException(e);
}
}
}

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

@ -18,16 +18,15 @@ package org.thingsboard.server.exception;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.msg.tools.TbRateLimitsException;
@ -39,8 +38,6 @@ import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
@Component
@Slf4j
@ -142,6 +139,10 @@ public class ThingsboardErrorResponseHandler implements AccessDeniedHandler {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
if (authenticationException instanceof BadCredentialsException) {
mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof DisabledException) {
mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof LockedException) {
mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof JwtExpiredTokenException) {
mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof AuthMethodNotSupportedException) {

15
application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java

@ -212,6 +212,21 @@ public class DefaultMailService implements MailService {
mailSender.send(helper.getMimeMessage());
}
@Override
public void sendAccountLockoutEmail( String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException {
String subject = messages.getMessage("account.lockout.subject", null, Locale.US);
Map<String, Object> model = new HashMap<String, Object>();
model.put("lockoutAccount", lockoutEmail);
model.put("maxFailedLoginAttempts", maxFailedLoginAttempts);
model.put(TARGET_EMAIL, email);
String message = mergeTemplateIntoString(this.engine,
"account.lockout.vm", UTF_8, model);
sendMail(mailSender, mailFrom, email, subject, message);
}
private void sendMail(JavaMailSenderImpl mailSender,
String mailFrom, String email,
String subject, String message) throws ThingsboardException {

20
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java

@ -19,13 +19,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.thingsboard.server.common.data.Customer;
@ -100,18 +99,21 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
throw new UsernameNotFoundException("User credentials not found");
}
systemSecurityService.validateUserCredentials(user.getTenantId(), userCredentials, password);
try {
systemSecurityService.validateUserCredentials(user.getTenantId(), userCredentials, username, password);
} catch (LockedException e) {
logLoginAction(user, authentication, ActionType.LOCKOUT, null);
throw e;
}
if (user.getAuthority() == null)
throw new InsufficientAuthenticationException("User has no authority assigned");
SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
logLoginAction(user, authentication, null);
logLoginAction(user, authentication, ActionType.LOGIN, null);
return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities());
} catch (Exception e) {
logLoginAction(user, authentication, e);
logLoginAction(user, authentication, ActionType.LOGIN, e);
throw e;
}
}
@ -148,7 +150,7 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
private void logLoginAction(User user, Authentication authentication, Exception e) {
private void logLoginAction(User user, Authentication authentication, ActionType actionType, Exception e) {
String clientAddress = "Unknown";
String browser = "Unknown";
String os = "Unknown";
@ -194,6 +196,6 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
}
auditLogService.logEntityAction(
user.getTenantId(), user.getCustomerId(), user.getId(),
user.getName(), user.getId(), null, ActionType.LOGIN, e, clientAddress, browser, os, device);
user.getName(), user.getId(), null, actionType, e, clientAddress, browser, os, device);
}
}

2
application/src/main/java/org/thingsboard/server/service/security/model/SecuritySettings.java

@ -24,4 +24,6 @@ public class SecuritySettings implements Serializable {
private UserPasswordPolicy passwordPolicy;
private Integer maxFailedLoginAttempts;
private String userLockoutNotificationEmail;
}

1
application/src/main/java/org/thingsboard/server/service/security/model/UserPasswordPolicy.java

@ -29,5 +29,6 @@ public class UserPasswordPolicy implements Serializable {
private Integer minimumSpecialCharacters;
private Integer passwordExpirationPeriodDays;
private Integer passwordReuseFrequencyDays;
}

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

@ -15,24 +15,38 @@
*/
package org.thingsboard.server.service.security.system;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.passay.*;
import org.apache.commons.lang3.StringUtils;
import org.passay.CharacterRule;
import org.passay.EnglishCharacterData;
import org.passay.LengthRule;
import org.passay.PasswordData;
import org.passay.PasswordValidator;
import org.passay.Rule;
import org.passay.RuleResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.user.UserServiceImpl;
import org.thingsboard.server.service.security.exception.UserPasswordExpiredException;
import org.thingsboard.server.service.security.model.SecuritySettings;
import org.thingsboard.server.service.security.model.UserPasswordPolicy;
@ -40,9 +54,9 @@ import org.thingsboard.server.service.security.model.UserPasswordPolicy;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE;
import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE;
@Service
@ -60,6 +74,9 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
@Autowired
private UserService userService;
@Autowired
private MailService mailService;
@Resource
private SystemSecurityService self;
@ -100,9 +117,23 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
}
@Override
public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String password) throws AuthenticationException {
public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException {
if (!encoder.matches(password, userCredentials.getPassword())) {
int failedLoginAttempts = userService.onUserLoginIncorrectCredentials(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = getSecuritySettings(tenantId);
if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) {
if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) {
userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userCredentials.getUserId(), false);
if (StringUtils.isNoneBlank(securitySettings.getUserLockoutNotificationEmail())) {
try {
mailService.sendAccountLockoutEmail(username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts());
} catch (ThingsboardException e) {
log.warn("Can't send email regarding user account [{}] lockout to provided email [{}]", username, securitySettings.getUserLockoutNotificationEmail(), e);
}
}
throw new LockedException("Authentication Failed. Username was locked due to security policy.");
}
}
throw new BadCredentialsException("Authentication Failed. Username or Password not valid.");
}
@ -110,6 +141,8 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
throw new DisabledException("User is not active");
}
userService.onUserLoginSuccessful(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = self.getSecuritySettings(tenantId);
if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) {
if ((userCredentials.getCreatedTime()
@ -122,7 +155,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
}
@Override
public void validatePassword(TenantId tenantId, String password) throws DataValidationException {
public void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException {
SecuritySettings securitySettings = self.getSecuritySettings(tenantId);
UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy();
@ -147,6 +180,22 @@ 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());
User user = userService.findUserById(tenantId, userCredentials.getUserId());
JsonNode additionalInfo = user.getAdditionalInfo();
if (additionalInfo instanceof ObjectNode && additionalInfo.has(UserServiceImpl.USER_PASSWORD_HISTORY)) {
JsonNode userPasswordHistoryJson = additionalInfo.get(UserServiceImpl.USER_PASSWORD_HISTORY);
Map<String, String> userPasswordHistoryMap = objectMapper.convertValue(userPasswordHistoryJson, Map.class);
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 static boolean isPositiveInteger(Integer val) {

4
application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java

@ -27,8 +27,8 @@ public interface SystemSecurityService {
SecuritySettings saveSecuritySettings(TenantId tenantId, SecuritySettings securitySettings);
void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String password) throws AuthenticationException;
void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException;
void validatePassword(TenantId tenantId, String password) throws DataValidationException;
void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException;
}

1
application/src/main/resources/i18n/messages.properties

@ -3,3 +3,4 @@ activation.subject=Your account activation on Thingsboard
account.activated.subject=Thingsboard - your account has been activated
reset.password.subject=Thingsboard - Password reset has been requested
password.was.reset.subject=Thingsboard - your account password has been reset
account.lockout.subject=Thingsboard - User account has been lockout

112
application/src/main/resources/templates/account.lockout.vm

@ -0,0 +1,112 @@
#*
* Copyright © 2016-2019 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.
*#
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Thingsboard - Account Lockout</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important; margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important; margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important; margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important; margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important; width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">
<table class="body-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
<td class="container" width="600" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
<div class="content" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;" valign="top">
<meta itemprop="name" content="Confirm Email" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" /><table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #348eda; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<h2>Thingsboard user account has been locked out</h2>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
Thingsboard user account $lockoutAccount has been lockout due to failed credentials were provided more than $maxFailedLoginAttempts times.
</td>
</tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
&mdash; The Thingsboard
</td>
</tr></table></td>
</tr>
</table>
<div class="footer" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;">
<table width="100%" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="aligncenter content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">This email was sent to <a href="mailto:$targetEmail" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; color: #999; text-decoration: underline; margin: 0;">$targetEmail</a> by Thingsboard.</td>
</tr>
</table>
</div>
</div>
</td>
<td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
</tr>
</table>
</body>
</html>

8
common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java

@ -32,13 +32,13 @@ import java.util.List;
public interface AuditLogService {
TimePageData<AuditLog> findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, List<ActionType> actionTypes, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, List<ActionType> actionTypes, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantId(TenantId tenantId, TimePageLink pageLink);
TimePageData<AuditLog> findAuditLogsByTenantId(TenantId tenantId, List<ActionType> actionTypes, TimePageLink pageLink);
<E extends HasName, I extends EntityId> ListenableFuture<List<Void>> logEntityAction(
TenantId tenantId,

7
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -60,5 +60,10 @@ public interface UserService {
TextPageData<User> findCustomerUsers(TenantId tenantId, CustomerId customerId, TextPageLink pageLink);
void deleteCustomerUsers(TenantId tenantId, CustomerId customerId);
void setUserCredentialsEnabled(TenantId tenantId, UserId userId, boolean enabled);
void onUserLoginSuccessful(TenantId tenantId, UserId userId);
int onUserLoginIncorrectCredentials(TenantId tenantId, UserId userId);
}

3
common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java

@ -39,7 +39,8 @@ public enum ActionType {
ALARM_ACK(false),
ALARM_CLEAR(false),
LOGIN(false),
LOGOUT(false);
LOGOUT(false),
LOCKOUT(false);
private final boolean isRead;

9
dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java

@ -16,6 +16,7 @@
package org.thingsboard.server.dao.audit;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
@ -37,11 +38,11 @@ public interface AuditLogDao {
ListenableFuture<Void> savePartitionsByTenantId(AuditLog auditLog);
List<AuditLog> findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List<ActionType> actionTypes, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, List<ActionType> actionTypes, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantId(UUID tenantId, TimePageLink pageLink);
List<AuditLog> findAuditLogsByTenantId(UUID tenantId, List<ActionType> actionTypes, TimePageLink pageLink);
}

17
dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java

@ -81,37 +81,37 @@ public class AuditLogServiceImpl implements AuditLogService {
private AuditLogSink auditLogSink;
@Override
public TimePageData<AuditLog> findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Executing findAuditLogsByTenantIdAndCustomerId [{}], [{}], [{}]", tenantId, customerId, pageLink);
validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
validateId(customerId, "Incorrect customerId " + customerId);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantIdAndCustomerId(tenantId.getId(), customerId, pageLink);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantIdAndCustomerId(tenantId.getId(), customerId, actionTypes, pageLink);
return new TimePageData<>(auditLogs, pageLink);
}
@Override
public TimePageData<AuditLog> findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Executing findAuditLogsByTenantIdAndUserId [{}], [{}], [{}]", tenantId, userId, pageLink);
validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
validateId(userId, "Incorrect userId" + userId);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantIdAndUserId(tenantId.getId(), userId, pageLink);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantIdAndUserId(tenantId.getId(), userId, actionTypes, pageLink);
return new TimePageData<>(auditLogs, pageLink);
}
@Override
public TimePageData<AuditLog> findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Executing findAuditLogsByTenantIdAndEntityId [{}], [{}], [{}]", tenantId, entityId, pageLink);
validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
validateEntityId(entityId, INCORRECT_TENANT_ID + entityId);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantIdAndEntityId(tenantId.getId(), entityId, pageLink);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantIdAndEntityId(tenantId.getId(), entityId, actionTypes, pageLink);
return new TimePageData<>(auditLogs, pageLink);
}
@Override
public TimePageData<AuditLog> findAuditLogsByTenantId(TenantId tenantId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantId(TenantId tenantId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Executing findAuditLogs [{}]", pageLink);
validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantId(tenantId.getId(), pageLink);
List<AuditLog> auditLogs = auditLogDao.findAuditLogsByTenantId(tenantId.getId(), actionTypes, pageLink);
return new TimePageData<>(auditLogs, pageLink);
}
@ -250,6 +250,7 @@ public class AuditLogServiceImpl implements AuditLogService {
break;
case LOGIN:
case LOGOUT:
case LOCKOUT:
String clientAddress = extractParameter(String.class, 0, additionalInfo);
String browser = extractParameter(String.class, 1, additionalInfo);
String os = extractParameter(String.class, 2, additionalInfo);

9
dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java

@ -29,6 +29,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
@ -273,7 +274,7 @@ public class CassandraAuditLogDao extends CassandraAbstractSearchTimeDao<AuditLo
}
@Override
public List<AuditLog> findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, TimePageLink pageLink) {
public List<AuditLog> findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Try to find audit logs by tenant [{}], entity [{}] and pageLink [{}]", tenantId, entityId, pageLink);
List<AuditLogEntity> entities = findPageWithTimeSearch(new TenantId(tenantId), AUDIT_LOG_BY_ENTITY_ID_CF,
Arrays.asList(eq(ModelConstants.AUDIT_LOG_TENANT_ID_PROPERTY, tenantId),
@ -285,7 +286,7 @@ public class CassandraAuditLogDao extends CassandraAbstractSearchTimeDao<AuditLo
}
@Override
public List<AuditLog> findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, TimePageLink pageLink) {
public List<AuditLog> findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Try to find audit logs by tenant [{}], customer [{}] and pageLink [{}]", tenantId, customerId, pageLink);
List<AuditLogEntity> entities = findPageWithTimeSearch(new TenantId(tenantId), AUDIT_LOG_BY_CUSTOMER_ID_CF,
Arrays.asList(eq(ModelConstants.AUDIT_LOG_TENANT_ID_PROPERTY, tenantId),
@ -296,7 +297,7 @@ public class CassandraAuditLogDao extends CassandraAbstractSearchTimeDao<AuditLo
}
@Override
public List<AuditLog> findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, TimePageLink pageLink) {
public List<AuditLog> findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Try to find audit logs by tenant [{}], user [{}] and pageLink [{}]", tenantId, userId, pageLink);
List<AuditLogEntity> entities = findPageWithTimeSearch(new TenantId(tenantId), AUDIT_LOG_BY_USER_ID_CF,
Arrays.asList(eq(ModelConstants.AUDIT_LOG_TENANT_ID_PROPERTY, tenantId),
@ -307,7 +308,7 @@ public class CassandraAuditLogDao extends CassandraAbstractSearchTimeDao<AuditLo
}
@Override
public List<AuditLog> findAuditLogsByTenantId(UUID tenantId, TimePageLink pageLink) {
public List<AuditLog> findAuditLogsByTenantId(UUID tenantId, List<ActionType> actionTypes, TimePageLink pageLink) {
log.trace("Try to find audit logs by tenant [{}] and pageLink [{}]", tenantId, pageLink);
long minPartition;

8
dao/src/main/java/org/thingsboard/server/dao/audit/DummyAuditLogServiceImpl.java

@ -37,22 +37,22 @@ import java.util.List;
public class DummyAuditLogServiceImpl implements AuditLogService {
@Override
public TimePageData<AuditLog> findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, List<ActionType> actionTypes, TimePageLink pageLink) {
return new TimePageData<>(null, pageLink);
}
@Override
public TimePageData<AuditLog> findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink) {
return new TimePageData<>(null, pageLink);
}
@Override
public TimePageData<AuditLog> findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, List<ActionType> actionTypes, TimePageLink pageLink) {
return new TimePageData<>(null, pageLink);
}
@Override
public TimePageData<AuditLog> findAuditLogsByTenantId(TenantId tenantId, TimePageLink pageLink) {
public TimePageData<AuditLog> findAuditLogsByTenantId(TenantId tenantId, List<ActionType> actionTypes, TimePageLink pageLink) {
return new TimePageData<>(null, pageLink);
}

4
dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java

@ -73,7 +73,9 @@ public class AdminSettingsServiceImpl implements AdminSettingsService {
if (!existentAdminSettings.getKey().equals(adminSettings.getKey())) {
throw new DataValidationException("Changing key of admin settings entry is prohibited!");
}
validateJsonStructure(existentAdminSettings.getJsonValue(), adminSettings.getJsonValue());
if (adminSettings.getKey().equals("mail")) {
validateJsonStructure(existentAdminSettings.getJsonValue(), adminSettings.getJsonValue());
}
}
}

34
dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java

@ -26,6 +26,7 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.UUIDConverter;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
@ -101,34 +102,34 @@ public class JpaAuditLogDao extends JpaAbstractDao<AuditLogEntity, AuditLog> imp
}
@Override
public List<AuditLog> findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, TimePageLink pageLink) {
return findAuditLogs(tenantId, entityId, null, null, pageLink);
public List<AuditLog> findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List<ActionType> actionTypes, TimePageLink pageLink) {
return findAuditLogs(tenantId, entityId, null, null, actionTypes, pageLink);
}
@Override
public List<AuditLog> findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, TimePageLink pageLink) {
return findAuditLogs(tenantId, null, customerId, null, pageLink);
public List<AuditLog> findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, List<ActionType> actionTypes, TimePageLink pageLink) {
return findAuditLogs(tenantId, null, customerId, null, actionTypes, pageLink);
}
@Override
public List<AuditLog> findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, TimePageLink pageLink) {
return findAuditLogs(tenantId, null, null, userId, pageLink);
public List<AuditLog> findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink) {
return findAuditLogs(tenantId, null, null, userId, actionTypes, pageLink);
}
@Override
public List<AuditLog> findAuditLogsByTenantId(UUID tenantId, TimePageLink pageLink) {
return findAuditLogs(tenantId, null, null, null, pageLink);
public List<AuditLog> findAuditLogsByTenantId(UUID tenantId, List<ActionType> actionTypes, TimePageLink pageLink) {
return findAuditLogs(tenantId, null, null, null, actionTypes, pageLink);
}
private List<AuditLog> findAuditLogs(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId, TimePageLink pageLink) {
private List<AuditLog> findAuditLogs(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId, List<ActionType> actionTypes, TimePageLink pageLink) {
Specification<AuditLogEntity> timeSearchSpec = JpaAbstractSearchTimeDao.getTimeSearchPageSpec(pageLink, "id");
Specification<AuditLogEntity> fieldsSpec = getEntityFieldsSpec(tenantId, entityId, customerId, userId);
Specification<AuditLogEntity> fieldsSpec = getEntityFieldsSpec(tenantId, entityId, customerId, userId, actionTypes);
Sort.Direction sortDirection = pageLink.isAscOrder() ? Sort.Direction.ASC : Sort.Direction.DESC;
Pageable pageable = new PageRequest(0, pageLink.getLimit(), sortDirection, ID_PROPERTY);
return DaoUtil.convertDataList(auditLogRepository.findAll(where(timeSearchSpec).and(fieldsSpec), pageable).getContent());
}
private Specification<AuditLogEntity> getEntityFieldsSpec(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId) {
private Specification<AuditLogEntity> getEntityFieldsSpec(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId, List<ActionType> actionTypes) {
return (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
if (tenantId != null) {
@ -142,12 +143,15 @@ public class JpaAuditLogDao extends JpaAbstractDao<AuditLogEntity, AuditLog> imp
predicates.add(entityIdPredicate);
}
if (customerId != null) {
Predicate tenantIdPredicate = criteriaBuilder.equal(root.get("customerId"), UUIDConverter.fromTimeUUID(customerId.getId()));
predicates.add(tenantIdPredicate);
Predicate customerIdPredicate = criteriaBuilder.equal(root.get("customerId"), UUIDConverter.fromTimeUUID(customerId.getId()));
predicates.add(customerIdPredicate);
}
if (userId != null) {
Predicate tenantIdPredicate = criteriaBuilder.equal(root.get("userId"), UUIDConverter.fromTimeUUID(userId.getId()));
predicates.add(tenantIdPredicate);
Predicate userIdPredicate = criteriaBuilder.equal(root.get("userId"), UUIDConverter.fromTimeUUID(userId.getId()));
predicates.add(userIdPredicate);
}
if (actionTypes != null && !actionTypes.isEmpty()) {
predicates.add(root.get("actionType").in(actionTypes));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[]{}));
};

123
dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java

@ -15,6 +15,9 @@
*/
package org.thingsboard.server.dao.user;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
@ -42,7 +45,9 @@ import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.tenant.TenantDao;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.service.Validator.validatePageLink;
@ -52,10 +57,19 @@ import static org.thingsboard.server.dao.service.Validator.validateString;
@Slf4j
public class UserServiceImpl extends AbstractEntityService implements UserService {
public static final String USER_PASSWORD_HISTORY = "userPasswordHistory";
private static final String LAST_LOGIN_TS = "lastLoginTs";
private static final String FAILED_LOGIN_ATTEMPTS = "failedLoginAttempts";
private static final int DEFAULT_TOKEN_LENGTH = 30;
public static final String INCORRECT_USER_ID = "Incorrect userId ";
public static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
private static final String USER_CREDENTIALS_ENABLED = "userCredentialsEnabled";
private static final ObjectMapper objectMapper = new ObjectMapper();
@Value("${security.user_login_case_sensitive:true}")
private boolean userLoginCaseSensitive;
@ -109,7 +123,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
userCredentials.setEnabled(false);
userCredentials.setActivateToken(RandomStringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH));
userCredentials.setUserId(new UserId(savedUser.getUuidId()));
userCredentialsDao.save(user.getTenantId(), userCredentials);
saveUserCredentialsAndPasswordHistory(user.getTenantId(), userCredentials);
}
return savedUser;
}
@ -139,7 +153,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
public UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials) {
log.trace("Executing saveUserCredentials [{}]", userCredentials);
userCredentialsValidator.validate(userCredentials, data -> tenantId);
return userCredentialsDao.save(tenantId, userCredentials);
return saveUserCredentialsAndPasswordHistory(tenantId, userCredentials);
}
@Override
@ -193,7 +207,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
userCredentialsValidator.validate(userCredentials, data -> tenantId);
userCredentialsDao.removeById(tenantId, userCredentials.getUuidId());
userCredentials.setId(null);
return userCredentialsDao.save(tenantId, userCredentials);
return saveUserCredentialsAndPasswordHistory(tenantId, userCredentials);
}
@Override
@ -240,6 +254,109 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic
customerUsersRemover.removeEntities(tenantId, customerId);
}
@Override
public void setUserCredentialsEnabled(TenantId tenantId, UserId userId, boolean enabled) {
log.trace("Executing setUserCredentialsEnabled [{}], [{}]", userId, enabled);
validateId(userId, INCORRECT_USER_ID + userId);
UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, userId.getId());
userCredentials.setEnabled(enabled);
saveUserCredentials(tenantId, userCredentials);
User user = findUserById(tenantId, userId);
JsonNode additionalInfo = user.getAdditionalInfo();
if (!(additionalInfo instanceof ObjectNode)) {
additionalInfo = objectMapper.createObjectNode();
}
((ObjectNode) additionalInfo).put(USER_CREDENTIALS_ENABLED, enabled);
user.setAdditionalInfo(additionalInfo);
if (enabled) {
resetFailedLoginAttempts(user);
}
userDao.save(user.getTenantId(), user);
}
@Override
public void onUserLoginSuccessful(TenantId tenantId, UserId userId) {
log.trace("Executing onUserLoginSuccessful [{}]", userId);
User user = findUserById(tenantId, userId);
setLastLoginTs(user);
resetFailedLoginAttempts(user);
saveUser(user);
}
private void setLastLoginTs(User user) {
JsonNode additionalInfo = user.getAdditionalInfo();
if (!(additionalInfo instanceof ObjectNode)) {
additionalInfo = objectMapper.createObjectNode();
}
((ObjectNode) additionalInfo).put(LAST_LOGIN_TS, System.currentTimeMillis());
user.setAdditionalInfo(additionalInfo);
}
private void resetFailedLoginAttempts(User user) {
JsonNode additionalInfo = user.getAdditionalInfo();
if (!(additionalInfo instanceof ObjectNode)) {
additionalInfo = objectMapper.createObjectNode();
}
((ObjectNode) additionalInfo).put(FAILED_LOGIN_ATTEMPTS, 0);
user.setAdditionalInfo(additionalInfo);
}
@Override
public int onUserLoginIncorrectCredentials(TenantId tenantId, UserId userId) {
log.trace("Executing onUserLoginIncorrectCredentials [{}]", userId);
User user = findUserById(tenantId, userId);
int failedLoginAttempts = increaseFailedLoginAttempts(user);
saveUser(user);
return failedLoginAttempts;
}
private int increaseFailedLoginAttempts(User user) {
JsonNode additionalInfo = user.getAdditionalInfo();
if (!(additionalInfo instanceof ObjectNode)) {
additionalInfo = objectMapper.createObjectNode();
}
int failedLoginAttempts = 0;
if (additionalInfo.has(FAILED_LOGIN_ATTEMPTS)) {
failedLoginAttempts = additionalInfo.get(FAILED_LOGIN_ATTEMPTS).asInt();
}
failedLoginAttempts = failedLoginAttempts + 1;
((ObjectNode) additionalInfo).put(FAILED_LOGIN_ATTEMPTS, failedLoginAttempts);
user.setAdditionalInfo(additionalInfo);
return failedLoginAttempts;
}
private UserCredentials saveUserCredentialsAndPasswordHistory(TenantId tenantId, UserCredentials userCredentials) {
UserCredentials result = userCredentialsDao.save(tenantId, userCredentials);
User user = findUserById(tenantId, userCredentials.getUserId());
if (userCredentials.getPassword() != null) {
updatePasswordHistory(user, userCredentials);
}
return result;
}
private void updatePasswordHistory(User user, UserCredentials userCredentials) {
JsonNode additionalInfo = user.getAdditionalInfo();
if (!(additionalInfo instanceof ObjectNode)) {
additionalInfo = objectMapper.createObjectNode();
}
if (additionalInfo.has(USER_PASSWORD_HISTORY)) {
JsonNode userPasswordHistoryJson = additionalInfo.get(USER_PASSWORD_HISTORY);
Map<String, String> userPasswordHistoryMap = objectMapper.convertValue(userPasswordHistoryJson, Map.class);
userPasswordHistoryMap.put(Long.toString(System.currentTimeMillis()), userCredentials.getPassword());
userPasswordHistoryJson = objectMapper.valueToTree(userPasswordHistoryMap);
((ObjectNode) additionalInfo).replace(USER_PASSWORD_HISTORY, userPasswordHistoryJson);
} else {
Map<String, String> userPasswordHistoryMap = new HashMap<>();
userPasswordHistoryMap.put(Long.toString(System.currentTimeMillis()), userCredentials.getPassword());
JsonNode userPasswordHistoryJson = objectMapper.valueToTree(userPasswordHistoryMap);
((ObjectNode) additionalInfo).set(USER_PASSWORD_HISTORY, userPasswordHistoryJson);
}
user.setAdditionalInfo(additionalInfo);
saveUser(user);
}
private DataValidator<User> userValidator =
new DataValidator<User>() {
@Override

6
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java

@ -15,12 +15,10 @@
*/
package org.thingsboard.rule.engine.api;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import com.fasterxml.jackson.databind.JsonNode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
public interface MailService {
@ -39,4 +37,6 @@ public interface MailService {
void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException;
void send(String from, String to, String cc, String bcc, String subject, String body) throws MessagingException;
void sendAccountLockoutEmail( String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException;
}

42
ui/src/app/admin/security-settings.tpl.html

@ -30,6 +30,37 @@
<form name="vm.settingsForm" ng-submit="vm.save()" tb-confirm-on-exit confirm-form="vm.settingsForm">
<fieldset ng-disabled="$root.loading">
<md-expansion-panel-group md-component-id="securitySettingsPanelGroup" auto-expand="true" multiple>
<md-expansion-panel md-component-id="generalPolicyPanel" id="generalPolicyPanel">
<md-expansion-panel-collapsed>
<div class="tb-panel-title" translate>admin.general-policy</div>
<md-expansion-panel-icon></md-expansion-panel-icon>
</md-expansion-panel-collapsed>
<md-expansion-panel-expanded>
<md-expansion-panel-header ng-click="vm.$mdExpansionPanel('generalPolicyPanel').collapse()">
<div class="tb-panel-title" translate>admin.general-policy</div>
<md-expansion-panel-icon></md-expansion-panel-icon>
</md-expansion-panel-header>
<md-expansion-panel-content>
<md-input-container class="md-block">
<label translate>admin.max-failed-login-attempts</label>
<input type="number"
step="1"
min="0"
name="maxFailedLoginAttempts"
ng-model="vm.securitySettings.maxFailedLoginAttempts">
<div ng-messages="vm.settingsForm.maxFailedLoginAttempts.$error">
<div translate ng-message="min">admin.minimum-max-failed-login-attempts-range</div>
</div>
</md-input-container>
<md-input-container class="md-block">
<label translate>admin.user-lockout-notification-email</label>
<input type="email"
name="userLockoutNotificationEmail"
ng-model="vm.securitySettings.userLockoutNotificationEmail">
</md-input-container>
</md-expansion-panel-content>
</md-expansion-panel-expanded>
</md-expansion-panel>
<md-expansion-panel md-component-id="passwordPolicyPanel" id="passwordPolicyPanel">
<md-expansion-panel-collapsed>
<div class="tb-panel-title" translate>admin.password-policy</div>
@ -111,6 +142,17 @@
<div translate ng-message="min">admin.password-expiration-period-days-range</div>
</div>
</md-input-container>
<md-input-container class="md-block">
<label translate>admin.password-reuse-frequency-days</label>
<input type="number"
step="1"
min="0"
name="passwordReuseFrequencyDays"
ng-model="vm.securitySettings.passwordPolicy.passwordReuseFrequencyDays">
<div ng-messages="vm.settingsForm.passwordReuseFrequencyDays.$error">
<div translate ng-message="min">admin.password-reuse-frequency-days-range</div>
</div>
</md-input-container>
</md-expansion-panel-content>
</md-expansion-panel-expanded>
</md-expansion-panel>

17
ui/src/app/api/user.service.js

@ -64,7 +64,8 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, time
logout: logout,
reloadUser: reloadUser,
isUserTokenAccessEnabled: isUserTokenAccessEnabled,
loginAsUser: loginAsUser
loginAsUser: loginAsUser,
setUserCredentialsEnabled: setUserCredentialsEnabled
}
reloadUser();
@ -496,6 +497,20 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, time
return deferred.promise;
}
function setUserCredentialsEnabled(userId, userCredentialsEnabled) {
var deferred = $q.defer();
var url = '/api/user/' + userId + '/userCredentialsEnabled';
if (angular.isDefined(userCredentialsEnabled)) {
url += '?userCredentialsEnabled=' + userCredentialsEnabled;
}
$http.post(url, null).then(function success() {
deferred.resolve();
}, function fail() {
deferred.reject();
});
return deferred.promise;
}
function getUser(userId, ignoreErrors, config) {
var deferred = $q.defer();
var url = '/api/user/' + userId;

3
ui/src/app/common/types.constant.js

@ -219,6 +219,9 @@ export default angular.module('thingsboard.types', [])
},
"LOGOUT": {
name: "audit-log.type-logout"
},
"LOCKOUT": {
name: "audit-log.type-lockout"
}
},
auditLogActionStatus: {

16
ui/src/app/locale/locale.constant-en_US.json

@ -100,7 +100,13 @@
"minimum-special-characters": "Minimum number of special characters",
"minimum-special-characters-range": "Minimum number of special characters can't be negative",
"password-expiration-period-days": "Password expiration period in days",
"password-expiration-period-days-range": "Password expiration period in days can't be negative"
"password-expiration-period-days-range": "Password expiration period in days can't be negative",
"password-reuse-frequency-days": "Password reuse frequency in days",
"password-reuse-frequency-days-range": "Password reuse frequency in days can't be negative",
"general-policy": "General policy",
"max-failed-login-attempts": "Maximum number of failed login attempts, before account is locked",
"minimum-max-failed-login-attempts-range": "Maximum number of failed login attempts can't be negative",
"user-lockout-notification-email": "In case user account lockout, send notification to email"
},
"alarm": {
"alarm": "Alarm",
@ -324,6 +330,7 @@
"type-alarm-clear": "Cleared",
"type-login": "Login",
"type-logout": "Logout",
"type-lockout": "Lockout",
"status-success": "Success",
"status-failure": "Failure",
"audit-log-details": "Audit log details",
@ -1216,6 +1223,7 @@
},
"profile": {
"profile": "Profile",
"last-login-time": "Last Login",
"change-password": "Change Password",
"current-password": "Current password"
},
@ -1456,7 +1464,11 @@
"activation-link-copied-message": "User activation link has been copied to clipboard",
"details": "Details",
"login-as-tenant-admin": "Login as Tenant Admin",
"login-as-customer-user": "Login as Customer User"
"login-as-customer-user": "Login as Customer User",
"disable-account": "Disable User Account",
"enable-account": "Enable User Account",
"enable-account-message": "User account was successfully enabled!",
"disable-account-message": "User account was successfully disabled!"
},
"value": {
"type": "Value type",

4
ui/src/app/profile/profile.tpl.html

@ -22,6 +22,10 @@
<span translate class="md-headline">profile.profile</span>
<span style='opacity: 0.7;'>{{ vm.profileUser.email }}</span>
</md-card-title-text>
<md-card-title-text>
<span translate class="md-subhead">profile.last-login-time</span>
<span style='opacity: 0.7;'>{{ vm.profileUser.additionalInfo.lastLoginTs | date : 'yyyy-MM-dd HH:mm:ss' }}</span>
</md-card-title-text>
</md-card-title>
<md-progress-linear md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
<span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>

6
ui/src/app/user/user-fieldset.tpl.html

@ -15,6 +15,12 @@
limitations under the License.
-->
<md-button ng-click="onSetUserCredentialsEnabled({userCredentialsEnabled: false})" ng-show="!isEdit && isUserCredentialsEnabled()" class="md-raised md-primary">{{ 'user.disable-account' |
translate }}
</md-button>
<md-button ng-click="onSetUserCredentialsEnabled({userCredentialsEnabled: true})" ng-show="!isEdit && !isUserCredentialsEnabled()" class="md-raised md-primary">{{ 'user.enable-account' |
translate }}
</md-button>
<md-button ng-click="onDisplayActivationLink({event: $event})" ng-show="!isEdit" class="md-raised md-primary">{{
'user.display-activation-link' | translate }}
</md-button>

17
ui/src/app/user/user.controller.js

@ -90,6 +90,7 @@ export default function UserController(userService, toast, $scope, $mdDialog, $d
vm.displayActivationLink = displayActivationLink;
vm.resendActivation = resendActivation;
vm.loginAsUser = loginAsUser;
vm.setUserCredentialsEnabled = setUserCredentialsEnabled;
initController();
@ -176,6 +177,22 @@ export default function UserController(userService, toast, $scope, $mdDialog, $d
);
}
function setUserCredentialsEnabled(user, userCredentialsEnabled) {
userService.setUserCredentialsEnabled(user.id.id, userCredentialsEnabled).then(
() => {
if (!user.additionalInfo) {
user.additionalInfo = {};
}
user.additionalInfo.userCredentialsEnabled = userCredentialsEnabled;
if (userCredentialsEnabled) {
toast.showSuccess($translate.instant('user.enable-account-message'));
} else {
toast.showSuccess($translate.instant('user.disable-account-message'));
}
}
)
}
function openActivationLinkDialog(event, activationLink) {
$mdDialog.show({
controller: 'ActivationLinkDialogController',

10
ui/src/app/user/user.directive.js

@ -35,6 +35,13 @@ export default function UserDirective($compile, $templateCache, userService) {
return scope.user && scope.user.authority === 'CUSTOMER_USER';
};
scope.isUserCredentialsEnabled = function() {
if (!scope.user || !scope.user.additionalInfo) {
return true;
}
return scope.user.additionalInfo.userCredentialsEnabled === true;
};
scope.loginAsUserEnabled = userService.isUserTokenAccessEnabled();
$compile(element.contents())(scope);
@ -49,7 +56,8 @@ export default function UserDirective($compile, $templateCache, userService) {
onDisplayActivationLink: '&',
onResendActivation: '&',
onLoginAsUser: '&',
onDeleteUser: '&'
onDeleteUser: '&',
onSetUserCredentialsEnabled: '&',
}
};
}

3
ui/src/app/user/users.tpl.html

@ -28,7 +28,8 @@
on-display-activation-link="vm.displayActivationLink(event, vm.grid.detailsConfig.currentItem)"
on-resend-activation="vm.resendActivation(vm.grid.detailsConfig.currentItem)"
on-login-as-user="vm.loginAsUser(vm.grid.detailsConfig.currentItem)"
on-delete-user="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)"></tb-user>
on-delete-user="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)"
on-set-user-credentials-enabled="vm.setUserCredentialsEnabled(vm.grid.detailsConfig.currentItem, userCredentialsEnabled)"></tb-user>
</md-tab>
<md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode && vm.grid.isTenantAdmin()" md-on-select="vm.grid.triggerResize()" label="{{ 'audit-log.audit-logs' | translate }}">
<tb-audit-log-table flex user-id="vm.grid.operatingItem().id.id"

Loading…
Cancel
Save