From 3935f616c7a30846211ec128879cca6653bafce7 Mon Sep 17 00:00:00 2001 From: VoBa Date: Tue, 10 Sep 2019 15:45:20 +0300 Subject: [PATCH] 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 --- .../server/controller/AuditLogController.java | 38 ++++-- .../server/controller/AuthController.java | 6 +- .../server/controller/UserController.java | 22 +++- .../ThingsboardErrorResponseHandler.java | 11 +- .../service/mail/DefaultMailService.java | 15 +++ .../auth/rest/RestAuthenticationProvider.java | 20 +-- .../security/model/SecuritySettings.java | 2 + .../security/model/UserPasswordPolicy.java | 1 + .../system/DefaultSystemSecurityService.java | 61 ++++++++- .../system/SystemSecurityService.java | 4 +- .../main/resources/i18n/messages.properties | 1 + .../resources/templates/account.lockout.vm | 112 ++++++++++++++++ .../server/dao/audit/AuditLogService.java | 8 +- .../server/dao/user/UserService.java | 7 +- .../server/common/data/audit/ActionType.java | 3 +- .../server/dao/audit/AuditLogDao.java | 9 +- .../server/dao/audit/AuditLogServiceImpl.java | 17 +-- .../dao/audit/CassandraAuditLogDao.java | 9 +- .../dao/audit/DummyAuditLogServiceImpl.java | 8 +- .../settings/AdminSettingsServiceImpl.java | 4 +- .../server/dao/sql/audit/JpaAuditLogDao.java | 34 ++--- .../server/dao/user/UserServiceImpl.java | 123 +++++++++++++++++- .../rule/engine/api/MailService.java | 6 +- ui/src/app/admin/security-settings.tpl.html | 42 ++++++ ui/src/app/api/user.service.js | 17 ++- ui/src/app/common/types.constant.js | 3 + ui/src/app/locale/locale.constant-en_US.json | 16 ++- ui/src/app/profile/profile.tpl.html | 4 + ui/src/app/user/user-fieldset.tpl.html | 6 + ui/src/app/user/user.controller.js | 17 +++ ui/src/app/user/user.directive.js | 10 +- ui/src/app/user/users.tpl.html | 3 +- 32 files changed, 551 insertions(+), 88 deletions(-) create mode 100644 application/src/main/resources/templates/account.lockout.vm diff --git a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java index 61fe7a384e..5047c42c70 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java +++ b/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 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 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 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 actionTypes = parseActionTypesStr(actionTypesStr); + return checkNotNull(auditLogService.findAuditLogsByTenantId(tenantId, actionTypes, pageLink)); } catch (Exception e) { throw handleException(e); } } + + private List parseActionTypesStr(String actionTypesStr) { + List 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; + } } diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 74aa27e1cf..690ba76a0f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/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); } diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index b6a8d9da06..aebe47b89d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/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); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index 6fc413dfb8..082f8fe0c1 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/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) { diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 217d62b54e..d8781d876e 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/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 model = new HashMap(); + 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 { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java index 435107d372..1e0f685622 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java +++ b/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); } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/SecuritySettings.java b/application/src/main/java/org/thingsboard/server/service/security/model/SecuritySettings.java index f7d795dde5..bfc9176b4b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/SecuritySettings.java +++ b/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; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/UserPasswordPolicy.java b/application/src/main/java/org/thingsboard/server/service/security/model/UserPasswordPolicy.java index 7b7a6ee334..0c50b5f6e0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/UserPasswordPolicy.java +++ b/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; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index ca959cea83..94d0fea86f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/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 userPasswordHistoryMap = objectMapper.convertValue(userPasswordHistoryJson, Map.class); + for (Map.Entry 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) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 224e754c92..50265863b3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/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; } diff --git a/application/src/main/resources/i18n/messages.properties b/application/src/main/resources/i18n/messages.properties index a78fbe07b3..a34582e132 100644 --- a/application/src/main/resources/i18n/messages.properties +++ b/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 \ No newline at end of file diff --git a/application/src/main/resources/templates/account.lockout.vm b/application/src/main/resources/templates/account.lockout.vm new file mode 100644 index 0000000000..9b735c8998 --- /dev/null +++ b/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. + *# + + + + + +Thingsboard - Account Lockout + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + +
+

Thingsboard user account has been locked out

+
+ Thingsboard user account $lockoutAccount has been lockout due to failed credentials were provided more than $maxFailedLoginAttempts times. +
+ — The Thingsboard +
+ +
+
+ + diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java index 90557dbabc..764ff806a3 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java +++ b/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 findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TimePageLink pageLink); + TimePageData findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, List actionTypes, TimePageLink pageLink); - TimePageData findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, TimePageLink pageLink); + TimePageData findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, List actionTypes, TimePageLink pageLink); - TimePageData findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TimePageLink pageLink); + TimePageData findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink); - TimePageData findAuditLogsByTenantId(TenantId tenantId, TimePageLink pageLink); + TimePageData findAuditLogsByTenantId(TenantId tenantId, List actionTypes, TimePageLink pageLink); ListenableFuture> logEntityAction( TenantId tenantId, diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index 2121b6c483..f5abe74f67 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -60,5 +60,10 @@ public interface UserService { TextPageData 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); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index 6e2b10f245..77357d2294 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/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; diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java index 2d8db71ab7..b00edf2241 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java +++ b/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 savePartitionsByTenantId(AuditLog auditLog); - List findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, TimePageLink pageLink); + List findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink); - List findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, TimePageLink pageLink); + List findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, List actionTypes, TimePageLink pageLink); - List findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, TimePageLink pageLink); + List findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, List actionTypes, TimePageLink pageLink); - List findAuditLogsByTenantId(UUID tenantId, TimePageLink pageLink); + List findAuditLogsByTenantId(UUID tenantId, List actionTypes, TimePageLink pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java index 3af67454d6..436415d9d9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java +++ b/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 findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, List actionTypes, TimePageLink pageLink) { log.trace("Executing findAuditLogsByTenantIdAndCustomerId [{}], [{}], [{}]", tenantId, customerId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(customerId, "Incorrect customerId " + customerId); - List auditLogs = auditLogDao.findAuditLogsByTenantIdAndCustomerId(tenantId.getId(), customerId, pageLink); + List auditLogs = auditLogDao.findAuditLogsByTenantIdAndCustomerId(tenantId.getId(), customerId, actionTypes, pageLink); return new TimePageData<>(auditLogs, pageLink); } @Override - public TimePageData findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, List actionTypes, TimePageLink pageLink) { log.trace("Executing findAuditLogsByTenantIdAndUserId [{}], [{}], [{}]", tenantId, userId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateId(userId, "Incorrect userId" + userId); - List auditLogs = auditLogDao.findAuditLogsByTenantIdAndUserId(tenantId.getId(), userId, pageLink); + List auditLogs = auditLogDao.findAuditLogsByTenantIdAndUserId(tenantId.getId(), userId, actionTypes, pageLink); return new TimePageData<>(auditLogs, pageLink); } @Override - public TimePageData findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink) { log.trace("Executing findAuditLogsByTenantIdAndEntityId [{}], [{}], [{}]", tenantId, entityId, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); validateEntityId(entityId, INCORRECT_TENANT_ID + entityId); - List auditLogs = auditLogDao.findAuditLogsByTenantIdAndEntityId(tenantId.getId(), entityId, pageLink); + List auditLogs = auditLogDao.findAuditLogsByTenantIdAndEntityId(tenantId.getId(), entityId, actionTypes, pageLink); return new TimePageData<>(auditLogs, pageLink); } @Override - public TimePageData findAuditLogsByTenantId(TenantId tenantId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantId(TenantId tenantId, List actionTypes, TimePageLink pageLink) { log.trace("Executing findAuditLogs [{}]", pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - List auditLogs = auditLogDao.findAuditLogsByTenantId(tenantId.getId(), pageLink); + List 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); diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java index 6ffaf1ea00..675a24a7f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java +++ b/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 findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, TimePageLink pageLink) { + public List findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink) { log.trace("Try to find audit logs by tenant [{}], entity [{}] and pageLink [{}]", tenantId, entityId, pageLink); List 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 findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, TimePageLink pageLink) { + public List findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, List actionTypes, TimePageLink pageLink) { log.trace("Try to find audit logs by tenant [{}], customer [{}] and pageLink [{}]", tenantId, customerId, pageLink); List 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 findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, TimePageLink pageLink) { + public List findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, List actionTypes, TimePageLink pageLink) { log.trace("Try to find audit logs by tenant [{}], user [{}] and pageLink [{}]", tenantId, userId, pageLink); List 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 findAuditLogsByTenantId(UUID tenantId, TimePageLink pageLink) { + public List findAuditLogsByTenantId(UUID tenantId, List actionTypes, TimePageLink pageLink) { log.trace("Try to find audit logs by tenant [{}] and pageLink [{}]", tenantId, pageLink); long minPartition; diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/DummyAuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/DummyAuditLogServiceImpl.java index b8fabd5f23..304bd8feb0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/DummyAuditLogServiceImpl.java +++ b/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 findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, List actionTypes, TimePageLink pageLink) { return new TimePageData<>(null, pageLink); } @Override - public TimePageData findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantIdAndUserId(TenantId tenantId, UserId userId, List actionTypes, TimePageLink pageLink) { return new TimePageData<>(null, pageLink); } @Override - public TimePageData findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink) { return new TimePageData<>(null, pageLink); } @Override - public TimePageData findAuditLogsByTenantId(TenantId tenantId, TimePageLink pageLink) { + public TimePageData findAuditLogsByTenantId(TenantId tenantId, List actionTypes, TimePageLink pageLink) { return new TimePageData<>(null, pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index 3de38d1a7b..20d0c4a077 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/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()); + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java index d6f5498acc..a2c18c541b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java +++ b/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 imp } @Override - public List findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, TimePageLink pageLink) { - return findAuditLogs(tenantId, entityId, null, null, pageLink); + public List findAuditLogsByTenantIdAndEntityId(UUID tenantId, EntityId entityId, List actionTypes, TimePageLink pageLink) { + return findAuditLogs(tenantId, entityId, null, null, actionTypes, pageLink); } @Override - public List findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, TimePageLink pageLink) { - return findAuditLogs(tenantId, null, customerId, null, pageLink); + public List findAuditLogsByTenantIdAndCustomerId(UUID tenantId, CustomerId customerId, List actionTypes, TimePageLink pageLink) { + return findAuditLogs(tenantId, null, customerId, null, actionTypes, pageLink); } @Override - public List findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, TimePageLink pageLink) { - return findAuditLogs(tenantId, null, null, userId, pageLink); + public List findAuditLogsByTenantIdAndUserId(UUID tenantId, UserId userId, List actionTypes, TimePageLink pageLink) { + return findAuditLogs(tenantId, null, null, userId, actionTypes, pageLink); } @Override - public List findAuditLogsByTenantId(UUID tenantId, TimePageLink pageLink) { - return findAuditLogs(tenantId, null, null, null, pageLink); + public List findAuditLogsByTenantId(UUID tenantId, List actionTypes, TimePageLink pageLink) { + return findAuditLogs(tenantId, null, null, null, actionTypes, pageLink); } - private List findAuditLogs(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId, TimePageLink pageLink) { + private List findAuditLogs(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId, List actionTypes, TimePageLink pageLink) { Specification timeSearchSpec = JpaAbstractSearchTimeDao.getTimeSearchPageSpec(pageLink, "id"); - Specification fieldsSpec = getEntityFieldsSpec(tenantId, entityId, customerId, userId); + Specification 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 getEntityFieldsSpec(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId) { + private Specification getEntityFieldsSpec(UUID tenantId, EntityId entityId, CustomerId customerId, UserId userId, List actionTypes) { return (root, criteriaQuery, criteriaBuilder) -> { List predicates = new ArrayList<>(); if (tenantId != null) { @@ -142,12 +143,15 @@ public class JpaAuditLogDao extends JpaAbstractDao 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[]{})); }; diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 9908fd17f6..2c4aa0a3a3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/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 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 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 userValidator = new DataValidator() { @Override diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java index 4e4a9d0c53..e040ffc25e 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java +++ b/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; } diff --git a/ui/src/app/admin/security-settings.tpl.html b/ui/src/app/admin/security-settings.tpl.html index 80656d569f..d0d34f2738 100644 --- a/ui/src/app/admin/security-settings.tpl.html +++ b/ui/src/app/admin/security-settings.tpl.html @@ -30,6 +30,37 @@
+ + +
admin.general-policy
+ +
+ + +
admin.general-policy
+ +
+ + + + +
+
admin.minimum-max-failed-login-attempts-range
+
+
+ + + + +
+
+
admin.password-policy
@@ -111,6 +142,17 @@
admin.password-expiration-period-days-range
+ + + +
+
admin.password-reuse-frequency-days-range
+
+
diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js index 67894d3b2a..38db6f5979 100644 --- a/ui/src/app/api/user.service.js +++ b/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; diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 125674ecfe..37ccd91302 100644 --- a/ui/src/app/common/types.constant.js +++ b/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: { diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 127407e825..006db27e14 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/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", diff --git a/ui/src/app/profile/profile.tpl.html b/ui/src/app/profile/profile.tpl.html index 4de27fd8fc..65cf46d3b3 100644 --- a/ui/src/app/profile/profile.tpl.html +++ b/ui/src/app/profile/profile.tpl.html @@ -22,6 +22,10 @@ profile.profile {{ vm.profileUser.email }} + + profile.last-login-time + {{ vm.profileUser.additionalInfo.lastLoginTs | date : 'yyyy-MM-dd HH:mm:ss' }} + diff --git a/ui/src/app/user/user-fieldset.tpl.html b/ui/src/app/user/user-fieldset.tpl.html index d783ad361d..0407c49194 100644 --- a/ui/src/app/user/user-fieldset.tpl.html +++ b/ui/src/app/user/user-fieldset.tpl.html @@ -15,6 +15,12 @@ limitations under the License. --> +{{ 'user.disable-account' | + translate }} + +{{ 'user.enable-account' | + translate }} + {{ 'user.display-activation-link' | translate }} diff --git a/ui/src/app/user/user.controller.js b/ui/src/app/user/user.controller.js index 63ceb75d20..f05af4e3d6 100644 --- a/ui/src/app/user/user.controller.js +++ b/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', diff --git a/ui/src/app/user/user.directive.js b/ui/src/app/user/user.directive.js index 2a173f4e3d..ba94b2140e 100644 --- a/ui/src/app/user/user.directive.js +++ b/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: '&', } }; } diff --git a/ui/src/app/user/users.tpl.html b/ui/src/app/user/users.tpl.html index 5a4663a35b..7c8dd85cc7 100644 --- a/ui/src/app/user/users.tpl.html +++ b/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)"> + on-delete-user="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)" + on-set-user-credentials-enabled="vm.setUserCredentialsEnabled(vm.grid.detailsConfig.currentItem, userCredentialsEnabled)">