diff --git a/application/pom.xml b/application/pom.xml index f61b0d762c..bfc5a300e7 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -272,6 +272,14 @@ io.springfox.ui springfox-swagger-ui-rfc6570 + + org.passay + passay + + + com.github.ua-parser + uap-java + diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 4575678ab5..a6122dd82e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -28,8 +28,10 @@ import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.service.security.model.SecuritySettings; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.security.system.SystemSecurityService; import org.thingsboard.server.service.update.UpdateService; import org.thingsboard.server.service.update.model.UpdateMessage; @@ -43,6 +45,9 @@ public class AdminController extends BaseController { @Autowired private AdminSettingsService adminSettingsService; + @Autowired + private SystemSecurityService systemSecurityService; + @Autowired private UpdateService updateService; @@ -74,6 +79,31 @@ public class AdminController extends BaseController { } } + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/securitySettings", method = RequestMethod.GET) + @ResponseBody + public SecuritySettings getSecuritySettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + return checkNotNull(systemSecurityService.getSecuritySettings(TenantId.SYS_TENANT_ID)); + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/securitySettings", method = RequestMethod.POST) + @ResponseBody + public SecuritySettings saveSecuritySettings(@RequestBody SecuritySettings securitySettings) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); + securitySettings = checkNotNull(systemSecurityService.saveSecuritySettings(TenantId.SYS_TENANT_ID, securitySettings)); + return securitySettings; + } catch (Exception e) { + throw handleException(e); + } + } + @PreAuthorize("hasAuthority('SYS_ADMIN')") @RequestMapping(value = "/settings/testMail", method = RequestMethod.POST) public void sendTestMail(@RequestBody AdminSettings adminSettings) throws ThingsboardException { 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 fdad8f09de..74aa27e1cf 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -24,6 +24,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -34,15 +35,24 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; 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.service.security.auth.jwt.RefreshTokenRepository; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.service.security.model.SecuritySettings; import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPasswordPolicy; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import ua_parser.Client; import javax.servlet.http.HttpServletRequest; import java.net.URI; @@ -65,6 +75,12 @@ public class AuthController extends BaseController { @Autowired private MailService mailService; + @Autowired + private SystemSecurityService systemSecurityService; + + @Autowired + private AuditLogService auditLogService; + @PreAuthorize("isAuthenticated()") @RequestMapping(value = "/auth/user", method = RequestMethod.GET) public @ResponseBody User getUser() throws ThingsboardException { @@ -76,6 +92,13 @@ public class AuthController extends BaseController { } } + @PreAuthorize("isAuthenticated()") + @RequestMapping(value = "/auth/logout", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void logout(HttpServletRequest request) throws ThingsboardException { + logLogoutAction(request); + } + @PreAuthorize("isAuthenticated()") @RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) @@ -89,8 +112,24 @@ 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); + if (passwordEncoder.matches(newPassword, userCredentials.getPassword())) { + throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } userCredentials.setPassword(passwordEncoder.encode(newPassword)); - userService.saveUserCredentials(securityUser.getTenantId(), userCredentials); + userService.replaceUserCredentials(securityUser.getTenantId(), userCredentials); + } catch (Exception e) { + throw handleException(e); + } + } + + @RequestMapping(value = "/noauth/userPasswordPolicy", method = RequestMethod.GET) + @ResponseBody + public UserPasswordPolicy getUserPasswordPolicy() throws ThingsboardException { + try { + SecuritySettings securitySettings = + checkNotNull(systemSecurityService.getSecuritySettings(TenantId.SYS_TENANT_ID)); + return securitySettings.getPasswordPolicy(); } catch (Exception e) { throw handleException(e); } @@ -167,6 +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); 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()); @@ -206,10 +246,14 @@ 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); + if (passwordEncoder.matches(password, userCredentials.getPassword())) { + throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } String encodedPassword = passwordEncoder.encode(password); userCredentials.setPassword(encodedPassword); userCredentials.setResetToken(null); - userCredentials = userService.saveUserCredentials(TenantId.SYS_TENANT_ID, userCredentials); + userCredentials = userService.replaceUserCredentials(TenantId.SYS_TENANT_ID, userCredentials); User user = userService.findUserById(TenantId.SYS_TENANT_ID, userCredentials.getUserId()); UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), principal); @@ -234,4 +278,54 @@ public class AuthController extends BaseController { } } + private void logLogoutAction(HttpServletRequest request) throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + RestAuthenticationDetails details = new RestAuthenticationDetails(request); + String clientAddress = details.getClientAddress(); + String browser = "Unknown"; + String os = "Unknown"; + String device = "Unknown"; + if (details.getUserAgent() != null) { + Client userAgent = details.getUserAgent(); + if (userAgent.userAgent != null) { + browser = userAgent.userAgent.family; + if (userAgent.userAgent.major != null) { + browser += " " + userAgent.userAgent.major; + if (userAgent.userAgent.minor != null) { + browser += "." + userAgent.userAgent.minor; + if (userAgent.userAgent.patch != null) { + browser += "." + userAgent.userAgent.patch; + } + } + } + } + if (userAgent.os != null) { + os = userAgent.os.family; + if (userAgent.os.major != null) { + os += " " + userAgent.os.major; + if (userAgent.os.minor != null) { + os += "." + userAgent.os.minor; + if (userAgent.os.patch != null) { + os += "." + userAgent.os.patch; + if (userAgent.os.patchMinor != null) { + os += "." + userAgent.os.patchMinor; + } + } + } + } + } + if (userAgent.device != null) { + device = userAgent.device.family; + } + } + auditLogService.logEntityAction( + user.getTenantId(), user.getCustomerId(), user.getId(), + user.getName(), user.getId(), null, ActionType.LOGOUT, null, clientAddress, browser, os, device); + + } catch (Exception e) { + throw handleException(e); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java new file mode 100644 index 0000000000..c56d00a59e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java @@ -0,0 +1,37 @@ +/** + * 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. + */ +package org.thingsboard.server.exception; + +import org.springframework.http.HttpStatus; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; + +public class ThingsboardCredentialsExpiredResponse extends ThingsboardErrorResponse { + + private final String resetToken; + + protected ThingsboardCredentialsExpiredResponse(String message, String resetToken) { + super(message, ThingsboardErrorCode.CREDENTIALS_EXPIRED, HttpStatus.UNAUTHORIZED); + this.resetToken = resetToken; + } + + public static ThingsboardCredentialsExpiredResponse of(final String message, final String resetToken) { + return new ThingsboardCredentialsExpiredResponse(message, resetToken); + } + + public String getResetToken() { + return resetToken; + } +} 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 ee8d198f59..6fc413dfb8 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -18,10 +18,12 @@ 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.core.AuthenticationException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; @@ -31,11 +33,14 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; import 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 @@ -141,8 +146,13 @@ public class ThingsboardErrorResponseHandler implements AccessDeniedHandler { mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)); } else if (authenticationException instanceof AuthMethodNotSupportedException) { mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of(authenticationException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); + } else if (authenticationException instanceof UserPasswordExpiredException) { + UserPasswordExpiredException expiredException = (UserPasswordExpiredException)authenticationException; + String resetToken = expiredException.getResetToken(); + mapper.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken)); + } else { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); } - mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java new file mode 100644 index 0000000000..5059cf800c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java @@ -0,0 +1,54 @@ +/** + * 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. + */ + +package org.thingsboard.server.service.security.auth.rest; + +import lombok.Data; +import ua_parser.Client; +import ua_parser.Parser; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.Serializable; + +@Data +public class RestAuthenticationDetails implements Serializable { + + private final String clientAddress; + private final Client userAgent; + + public RestAuthenticationDetails(HttpServletRequest request) { + this.clientAddress = getClientIP(request); + this.userAgent = getUserAgent(request); + } + + private static String getClientIP(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader == null) { + return request.getRemoteAddr(); + } + return xfHeader.split(",")[0]; + } + + private static Client getUserAgent(HttpServletRequest request) { + try { + Parser uaParser = new Parser(); + return uaParser.parse(request.getHeader("User-Agent")); + } catch (IOException e) { + return new Client(null, null, null); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java new file mode 100644 index 0000000000..3983c46306 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java @@ -0,0 +1,29 @@ +/** + * 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. + */ + +package org.thingsboard.server.service.security.auth.rest; + +import org.springframework.security.authentication.AuthenticationDetailsSource; + +import javax.servlet.http.HttpServletRequest; + +public class RestAuthenticationDetailsSource implements + AuthenticationDetailsSource { + + public RestAuthenticationDetails buildDetails(HttpServletRequest context) { + return new RestAuthenticationDetails(context); + } +} 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 72f9dd61fa..435107d372 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.auth.rest; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; @@ -29,31 +30,41 @@ import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import ua_parser.Client; import java.util.UUID; @Component +@Slf4j public class RestAuthenticationProvider implements AuthenticationProvider { - private final BCryptPasswordEncoder encoder; + private final SystemSecurityService systemSecurityService; private final UserService userService; private final CustomerService customerService; + private final AuditLogService auditLogService; @Autowired - public RestAuthenticationProvider(final UserService userService, final CustomerService customerService, final BCryptPasswordEncoder encoder) { + public RestAuthenticationProvider(final UserService userService, + final CustomerService customerService, + final SystemSecurityService systemSecurityService, + final AuditLogService auditLogService) { this.userService = userService; this.customerService = customerService; - this.encoder = encoder; + this.systemSecurityService = systemSecurityService; + this.auditLogService = auditLogService; } @Override @@ -69,37 +80,40 @@ public class RestAuthenticationProvider implements AuthenticationProvider { if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) { String username = userPrincipal.getValue(); String password = (String) authentication.getCredentials(); - return authenticateByUsernameAndPassword(userPrincipal, username, password); + return authenticateByUsernameAndPassword(authentication, userPrincipal, username, password); } else { String publicId = userPrincipal.getValue(); return authenticateByPublicId(userPrincipal, publicId); } } - private Authentication authenticateByUsernameAndPassword(UserPrincipal userPrincipal, String username, String password) { + private Authentication authenticateByUsernameAndPassword(Authentication authentication, UserPrincipal userPrincipal, String username, String password) { User user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, username); if (user == null) { throw new UsernameNotFoundException("User not found: " + username); } - UserCredentials userCredentials = userService.findUserCredentialsByUserId(TenantId.SYS_TENANT_ID, user.getId()); - if (userCredentials == null) { - throw new UsernameNotFoundException("User credentials not found"); - } + try { - if (!userCredentials.isEnabled()) { - throw new DisabledException("User is not active"); - } + UserCredentials userCredentials = userService.findUserCredentialsByUserId(TenantId.SYS_TENANT_ID, user.getId()); + if (userCredentials == null) { + throw new UsernameNotFoundException("User credentials not found"); + } - if (!encoder.matches(password, userCredentials.getPassword())) { - throw new BadCredentialsException("Authentication Failed. Username or Password not valid."); - } + systemSecurityService.validateUserCredentials(user.getTenantId(), userCredentials, password); - if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned"); + if (user.getAuthority() == null) + throw new InsufficientAuthenticationException("User has no authority assigned"); - SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); + SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); - return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities()); + logLoginAction(user, authentication, null); + + return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities()); + } catch (Exception e) { + logLoginAction(user, authentication, e); + throw e; + } } private Authentication authenticateByPublicId(UserPrincipal userPrincipal, String publicId) { @@ -133,4 +147,53 @@ public class RestAuthenticationProvider implements AuthenticationProvider { public boolean supports(Class> authentication) { return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication)); } + + private void logLoginAction(User user, Authentication authentication, Exception e) { + String clientAddress = "Unknown"; + String browser = "Unknown"; + String os = "Unknown"; + String device = "Unknown"; + if (authentication != null && authentication.getDetails() != null) { + if (authentication.getDetails() instanceof RestAuthenticationDetails) { + RestAuthenticationDetails details = (RestAuthenticationDetails)authentication.getDetails(); + clientAddress = details.getClientAddress(); + if (details.getUserAgent() != null) { + Client userAgent = details.getUserAgent(); + if (userAgent.userAgent != null) { + browser = userAgent.userAgent.family; + if (userAgent.userAgent.major != null) { + browser += " " + userAgent.userAgent.major; + if (userAgent.userAgent.minor != null) { + browser += "." + userAgent.userAgent.minor; + if (userAgent.userAgent.patch != null) { + browser += "." + userAgent.userAgent.patch; + } + } + } + } + if (userAgent.os != null) { + os = userAgent.os.family; + if (userAgent.os.major != null) { + os += " " + userAgent.os.major; + if (userAgent.os.minor != null) { + os += "." + userAgent.os.minor; + if (userAgent.os.patch != null) { + os += "." + userAgent.os.patch; + if (userAgent.os.patchMinor != null) { + os += "." + userAgent.os.patchMinor; + } + } + } + } + } + if (userAgent.device != null) { + device = userAgent.device.family; + } + } + } + } + auditLogService.logEntityAction( + user.getTenantId(), user.getCustomerId(), user.getId(), + user.getName(), user.getId(), null, ActionType.LOGIN, e, clientAddress, browser, os, device); + } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java index f233d5c516..77f7908b03 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -27,6 +28,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -39,6 +41,8 @@ import java.io.IOException; @Slf4j public class RestLoginProcessingFilter extends AbstractAuthenticationProcessingFilter { + private final AuthenticationDetailsSource authenticationDetailsSource = new RestAuthenticationDetailsSource(); + private final AuthenticationSuccessHandler successHandler; private final AuthenticationFailureHandler failureHandler; @@ -76,7 +80,7 @@ public class RestLoginProcessingFilter extends AbstractAuthenticationProcessingF UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, loginRequest.getUsername()); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(principal, loginRequest.getPassword()); - + token.setDetails(authenticationDetailsSource.buildDetails(request)); return this.getAuthenticationManager().authenticate(token); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java b/application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java new file mode 100644 index 0000000000..993d09bbf0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java @@ -0,0 +1,33 @@ +/** + * 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. + */ +package org.thingsboard.server.service.security.exception; + +import org.springframework.security.authentication.CredentialsExpiredException; + +public class UserPasswordExpiredException extends CredentialsExpiredException { + + private final String resetToken; + + public UserPasswordExpiredException(String msg, String resetToken) { + super(msg); + this.resetToken = resetToken; + } + + public String getResetToken() { + return resetToken; + } + +} 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 new file mode 100644 index 0000000000..19da818d32 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/SecuritySettings.java @@ -0,0 +1,25 @@ +/** + * 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. + */ +package org.thingsboard.server.service.security.model; + +import lombok.Data; + +@Data +public class SecuritySettings { + + private UserPasswordPolicy passwordPolicy; + +} 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 new file mode 100644 index 0000000000..6d570d4c9a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/UserPasswordPolicy.java @@ -0,0 +1,31 @@ +/** + * 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. + */ +package org.thingsboard.server.service.security.model; + +import lombok.Data; + +@Data +public class UserPasswordPolicy { + + private Integer minimumLength; + private Integer minimumUppercaseLetters; + private Integer minimumLowercaseLetters; + private Integer minimumDigits; + private Integer minimumSpecialCharacters; + + private Integer passwordExpirationPeriodDays; + +} 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 new file mode 100644 index 0000000000..ca959cea83 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -0,0 +1,155 @@ +/** + * 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. + */ +package org.thingsboard.server.service.security.system; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.passay.*; +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.core.AuthenticationException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.UserCredentials; +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.service.security.exception.UserPasswordExpiredException; +import org.thingsboard.server.service.security.model.SecuritySettings; +import org.thingsboard.server.service.security.model.UserPasswordPolicy; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.List; +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 +@Slf4j +public class DefaultSystemSecurityService implements SystemSecurityService { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private AdminSettingsService adminSettingsService; + + @Autowired + private BCryptPasswordEncoder encoder; + + @Autowired + private UserService userService; + + @Resource + private SystemSecurityService self; + + @Cacheable(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'") + @Override + public SecuritySettings getSecuritySettings(TenantId tenantId) { + SecuritySettings securitySettings = null; + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(tenantId, "securitySettings"); + if (adminSettings != null) { + try { + securitySettings = objectMapper.treeToValue(adminSettings.getJsonValue(), SecuritySettings.class); + } catch (Exception e) { + throw new RuntimeException("Failed to load security settings!", e); + } + } else { + securitySettings = new SecuritySettings(); + securitySettings.setPasswordPolicy(new UserPasswordPolicy()); + securitySettings.getPasswordPolicy().setMinimumLength(6); + } + return securitySettings; + } + + @CacheEvict(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'") + @Override + public SecuritySettings saveSecuritySettings(TenantId tenantId, SecuritySettings securitySettings) { + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(tenantId, "securitySettings"); + if (adminSettings == null) { + adminSettings = new AdminSettings(); + adminSettings.setKey("securitySettings"); + } + adminSettings.setJsonValue(objectMapper.valueToTree(securitySettings)); + AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(tenantId, adminSettings); + try { + return objectMapper.treeToValue(savedAdminSettings.getJsonValue(), SecuritySettings.class); + } catch (Exception e) { + throw new RuntimeException("Failed to load security settings!", e); + } + } + + @Override + public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String password) throws AuthenticationException { + + if (!encoder.matches(password, userCredentials.getPassword())) { + throw new BadCredentialsException("Authentication Failed. Username or Password not valid."); + } + + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) { + if ((userCredentials.getCreatedTime() + + TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) + < System.currentTimeMillis()) { + userCredentials = userService.requestExpiredPasswordReset(tenantId, userCredentials.getId()); + throw new UserPasswordExpiredException("User password expired!", userCredentials.getResetToken()); + } + } + } + + @Override + public void validatePassword(TenantId tenantId, String password) throws DataValidationException { + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); + + List passwordRules = new ArrayList<>(); + passwordRules.add(new LengthRule(passwordPolicy.getMinimumLength(), Integer.MAX_VALUE)); + if (isPositiveInteger(passwordPolicy.getMinimumUppercaseLetters())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.UpperCase, passwordPolicy.getMinimumUppercaseLetters())); + } + if (isPositiveInteger(passwordPolicy.getMinimumLowercaseLetters())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.LowerCase, passwordPolicy.getMinimumLowercaseLetters())); + } + if (isPositiveInteger(passwordPolicy.getMinimumDigits())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.Digit, passwordPolicy.getMinimumDigits())); + } + if (isPositiveInteger(passwordPolicy.getMinimumSpecialCharacters())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.Special, passwordPolicy.getMinimumSpecialCharacters())); + } + PasswordValidator validator = new PasswordValidator(passwordRules); + PasswordData passwordData = new PasswordData(password); + RuleResult result = validator.validate(passwordData); + if (!result.isValid()) { + String message = String.join("\n", validator.getMessages(result)); + throw new DataValidationException(message); + } + } + + private static boolean isPositiveInteger(Integer val) { + return val != null && val.intValue() > 0; + } +} 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 new file mode 100644 index 0000000000..224e754c92 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.service.security.system; + +import org.springframework.security.core.AuthenticationException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.service.security.model.SecuritySettings; + +public interface SystemSecurityService { + + SecuritySettings getSecuritySettings(TenantId tenantId); + + SecuritySettings saveSecuritySettings(TenantId tenantId, SecuritySettings securitySettings); + + void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String password) throws AuthenticationException; + + void validatePassword(TenantId tenantId, String password) throws DataValidationException; + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 0e291db2cc..13c0ad767e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -269,6 +269,9 @@ caffeine: claimDevices: timeToLiveInMinutes: 1 maxSize: 100000 + securitySettings: + timeToLiveInMinutes: 1440 + maxSize: 1 redis: # standalone or cluster diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java index 72b8ccf89a..696d836f42 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java @@ -105,18 +105,6 @@ public abstract class BaseAdminControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString("Provided json structure is different"))); } - @Test - public void testSaveAdminSettingsWithNonTextValue() throws Exception { - loginSysAdmin(); - AdminSettings adminSettings = doGet("/api/admin/settings/mail", AdminSettings.class); - JsonNode json = adminSettings.getJsonValue(); - ((ObjectNode) json).put("timeout", 10000L); - adminSettings.setJsonValue(json); - doPost("/api/admin/settings", adminSettings) - .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Provided json structure can't contain non-text values"))); - } - @Test public void testSendTestMail() throws Exception { loginSysAdmin(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 67f0fb9a0b..f15997936c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -23,4 +23,5 @@ public class CacheConstants { public static final String ASSET_CACHE = "assets"; public static final String ENTITY_VIEW_CACHE = "entityViews"; public static final String CLAIM_DEVICES_CACHE = "claimDevices"; + public static final String SECURITY_SETTINGS_CACHE = "securitySettings"; } 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 84eaf99fa3..6e2b10f245 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 @@ -37,7 +37,9 @@ public enum ActionType { RELATION_DELETED(false), RELATIONS_DELETED(false), ALARM_ACK(false), - ALARM_CLEAR(false); + ALARM_CLEAR(false), + LOGIN(false), + LOGOUT(false); private final boolean isRead; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java index e11e96ac0c..1393712de8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java @@ -22,6 +22,7 @@ public enum ThingsboardErrorCode { GENERAL(2), AUTHENTICATION(10), JWT_TOKEN_EXPIRED(11), + CREDENTIALS_EXPIRED(15), PERMISSION_DENIED(20), INVALID_ARGUMENTS(30), BAD_REQUEST_PARAMS(31), 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 ad3db8781f..3af67454d6 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 @@ -248,6 +248,17 @@ public class AuditLogServiceImpl implements AuditLogService { EntityRelation relation = extractParameter(EntityRelation.class, 0, additionalInfo); actionData.set("relation", objectMapper.valueToTree(relation)); break; + case LOGIN: + case LOGOUT: + String clientAddress = extractParameter(String.class, 0, additionalInfo); + String browser = extractParameter(String.class, 1, additionalInfo); + String os = extractParameter(String.class, 2, additionalInfo); + String device = extractParameter(String.class, 3, additionalInfo); + actionData.put("clientAddress", clientAddress); + actionData.put("browser", browser); + actionData.put("os", os); + actionData.put("device", device); + break; } return actionData; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java index e016f2fcf7..5c21c5348b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java @@ -85,11 +85,5 @@ public abstract class DataValidator> { if (!expectedFields.containsAll(actualFields) || !actualFields.containsAll(expectedFields)) { throw new DataValidationException("Provided json structure is different from stored one '" + actualNode + "'!"); } - - for (String field : actualFields) { - if (!actualNode.get(field).isTextual()) { - throw new DataValidationException("Provided json structure can't contain non-text values '" + actualNode + "'!"); - } - } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserService.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserService.java index fe53b09bf6..2121b6c483 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; @@ -46,6 +47,10 @@ public interface UserService { UserCredentials requestPasswordReset(TenantId tenantId, String email); + UserCredentials requestExpiredPasswordReset(TenantId tenantId, UserCredentialsId userCredentialsId); + + UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); + void deleteUser(TenantId tenantId, UserId userId); TextPageData findTenantAdmins(TenantId tenantId, TextPageLink pageLink); 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 8704026a66..9908fd17f6 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 @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; @@ -176,6 +177,24 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic return saveUserCredentials(tenantId, userCredentials); } + @Override + public UserCredentials requestExpiredPasswordReset(TenantId tenantId, UserCredentialsId userCredentialsId) { + UserCredentials userCredentials = userCredentialsDao.findById(tenantId, userCredentialsId.getId()); + if (!userCredentials.isEnabled()) { + throw new IncorrectParameterException("Unable to reset password for inactive user"); + } + userCredentials.setResetToken(RandomStringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH)); + return saveUserCredentials(tenantId, userCredentials); + } + + @Override + public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials) { + log.trace("Executing replaceUserCredentials [{}]", userCredentials); + userCredentialsValidator.validate(userCredentials, data -> tenantId); + userCredentialsDao.removeById(tenantId, userCredentials.getUuidId()); + userCredentials.setId(null); + return userCredentialsDao.save(tenantId, userCredentials); + } @Override public void deleteUser(TenantId tenantId, UserId userId) { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseAdminSettingsServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/BaseAdminSettingsServiceTest.java index 164396e667..00c1e456ff 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseAdminSettingsServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/BaseAdminSettingsServiceTest.java @@ -76,13 +76,4 @@ public abstract class BaseAdminSettingsServiceTest extends AbstractServiceTest { adminSettings.setJsonValue(json); adminSettingsService.saveAdminSettings(SYSTEM_TENANT_ID, adminSettings); } - - @Test(expected = DataValidationException.class) - public void testSaveAdminSettingsWithNonTextValue() { - AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(SYSTEM_TENANT_ID, "mail"); - JsonNode json = adminSettings.getJsonValue(); - ((ObjectNode) json).put("timeout", 10000L); - adminSettings.setJsonValue(json); - adminSettingsService.saveAdminSettings(SYSTEM_TENANT_ID, adminSettings); - } } diff --git a/msa/js-executor/package-lock.json b/msa/js-executor/package-lock.json index 7c2c0700ce..435a3d74c4 100644 --- a/msa/js-executor/package-lock.json +++ b/msa/js-executor/package-lock.json @@ -1,6 +1,6 @@ { "name": "thingsboard-js-executor", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/msa/web-ui/package-lock.json b/msa/web-ui/package-lock.json index d6a81087b6..2ce8b22c11 100644 --- a/msa/web-ui/package-lock.json +++ b/msa/web-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "thingsboard-web-ui", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/pom.xml b/pom.xml index 0a3dd04ca6..2b4adb9d0f 100755 --- a/pom.xml +++ b/pom.xml @@ -89,6 +89,8 @@ 2.57 2.7.7 1.23 + 1.5.0 + 1.4.3 @@ -840,6 +842,16 @@ jts-core ${jts.version} + + org.passay + passay + ${passay.version} + + + com.github.ua-parser + uap-java + ${ua-parser.version} + diff --git a/ui/package-lock.json b/ui/package-lock.json index 37ba50bf45..9f21691202 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/ui/src/app/admin/admin.routes.js b/ui/src/app/admin/admin.routes.js index bc2ce7c239..23e03214b3 100644 --- a/ui/src/app/admin/admin.routes.js +++ b/ui/src/app/admin/admin.routes.js @@ -17,6 +17,7 @@ import generalSettingsTemplate from '../admin/general-settings.tpl.html'; import outgoingMailSettingsTemplate from '../admin/outgoing-mail-settings.tpl.html'; +import securitySettingsTemplate from '../admin/security-settings.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ @@ -69,5 +70,23 @@ export default function AdminRoutes($stateProvider) { ncyBreadcrumb: { label: '{"icon": "mail", "label": "admin.outgoing-mail"}' } + }) + .state('home.settings.security-settings', { + url: '/security-settings', + module: 'private', + auth: ['SYS_ADMIN'], + views: { + "content@home": { + templateUrl: securitySettingsTemplate, + controllerAs: 'vm', + controller: 'SecuritySettingsController' + } + }, + data: { + pageTitle: 'admin.security-settings' + }, + ncyBreadcrumb: { + label: '{"icon": "security", "label": "admin.security-settings"}' + } }); } diff --git a/ui/src/app/admin/index.js b/ui/src/app/admin/index.js index ecfb3692fa..5eebc73b37 100644 --- a/ui/src/app/admin/index.js +++ b/ui/src/app/admin/index.js @@ -22,6 +22,7 @@ import thingsboardToast from '../services/toast'; import AdminRoutes from './admin.routes'; import AdminController from './admin.controller'; +import SecuritySettingsController from './security-settings.controller'; export default angular.module('thingsboard.admin', [ uiRouter, @@ -33,4 +34,5 @@ export default angular.module('thingsboard.admin', [ ]) .config(AdminRoutes) .controller('AdminController', AdminController) + .controller('SecuritySettingsController', SecuritySettingsController) .name; diff --git a/ui/src/app/admin/security-settings.controller.js b/ui/src/app/admin/security-settings.controller.js new file mode 100644 index 0000000000..4b024f5dd8 --- /dev/null +++ b/ui/src/app/admin/security-settings.controller.js @@ -0,0 +1,42 @@ +/* + * 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. + */ + +import './settings-card.scss'; + +/*@ngInject*/ +export default function SecuritySettingsController(adminService, $mdExpansionPanel) { + + var vm = this; + vm.$mdExpansionPanel = $mdExpansionPanel; + + vm.save = save; + + loadSettings(); + + function loadSettings() { + adminService.getSecuritySettings().then(function success(securitySettings) { + vm.securitySettings = securitySettings; + }); + } + + function save() { + adminService.saveSecuritySettings(vm.securitySettings).then(function success(securitySettings) { + vm.securitySettings = securitySettings; + vm.settingsForm.$setPristine(); + }); + } + +} diff --git a/ui/src/app/admin/security-settings.tpl.html b/ui/src/app/admin/security-settings.tpl.html new file mode 100644 index 0000000000..80656d569f --- /dev/null +++ b/ui/src/app/admin/security-settings.tpl.html @@ -0,0 +1,125 @@ + + + + + + admin.security-settings + + + + + + + + + + + + + admin.password-policy + + + + + admin.password-policy + + + + + admin.minimum-password-length + + + admin.minimum-password-length-required + admin.minimum-password-length-range + admin.minimum-password-length-range + + + + admin.minimum-uppercase-letters + + + admin.minimum-uppercase-letters-range + + + + admin.minimum-lowercase-letters + + + admin.minimum-lowercase-letters-range + + + + admin.minimum-digits + + + admin.minimum-digits-range + + + + admin.minimum-special-characters + + + admin.minimum-special-characters-range + + + + admin.password-expiration-period-days + + + admin.password-expiration-period-days-range + + + + + + + + {{'action.save' | translate}} + + + + + + diff --git a/ui/src/app/admin/settings-card.scss b/ui/src/app/admin/settings-card.scss index 9cbf5266b0..cf66301787 100644 --- a/ui/src/app/admin/settings-card.scss +++ b/ui/src/app/admin/settings-card.scss @@ -20,4 +20,8 @@ md-card.settings-card { @media (min-width: $layout-breakpoint-sm) { width: 60%; } + + md-icon.md-expansion-panel-icon { + margin-right: 0; + } } diff --git a/ui/src/app/api/admin.service.js b/ui/src/app/api/admin.service.js index b87c6224f0..6afa79978d 100644 --- a/ui/src/app/api/admin.service.js +++ b/ui/src/app/api/admin.service.js @@ -23,6 +23,8 @@ function AdminService($http, $q) { var service = { getAdminSettings: getAdminSettings, saveAdminSettings: saveAdminSettings, + getSecuritySettings: getSecuritySettings, + saveSecuritySettings: saveSecuritySettings, sendTestMail: sendTestMail, checkUpdates: checkUpdates } @@ -51,6 +53,28 @@ function AdminService($http, $q) { return deferred.promise; } + function getSecuritySettings() { + var deferred = $q.defer(); + var url = '/api/admin/securitySettings'; + $http.get(url, null).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function saveSecuritySettings(securitySettings) { + var deferred = $q.defer(); + var url = '/api/admin/securitySettings'; + $http.post(url, securitySettings).then(function success(response) { + deferred.resolve(response.data); + }, function fail(response) { + deferred.reject(response.data); + }); + return deferred.promise; + } + function sendTestMail(settings) { var deferred = $q.defer(); var url = '/api/admin/settings/testMail'; diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js index ffcdc5f896..67894d3b2a 100644 --- a/ui/src/app/api/user.service.js +++ b/ui/src/app/api/user.service.js @@ -141,7 +141,11 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, time } function logout() { - clearJwtToken(true); + $http.post('/api/auth/logout', null, {ignoreErrors: true}).then(function success() { + clearJwtToken(true); + }, function fail() { + clearJwtToken(true); + }); } function clearJwtToken(doLogout) { diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 8d5468fd8d..7267f0e223 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -20,6 +20,7 @@ export default angular.module('thingsboard.types', []) general: 2, authentication: 10, jwtTokenExpired: 11, + credentialsExpired: 15, permissionDenied: 20, invalidArguments: 30, badRequestParams: 31, @@ -212,6 +213,12 @@ export default angular.module('thingsboard.types', []) }, "ALARM_CLEAR": { name: "audit-log.type-alarm-clear" + }, + "LOGIN": { + name: "audit-log.type-login" + }, + "LOGOUT": { + name: "audit-log.type-logout" } }, auditLogActionStatus: { diff --git a/ui/src/app/global-interceptor.service.js b/ui/src/app/global-interceptor.service.js index 2d4eb43132..2f87b0ec14 100644 --- a/ui/src/app/global-interceptor.service.js +++ b/ui/src/app/global-interceptor.service.js @@ -170,7 +170,7 @@ export default function GlobalInterceptor($rootScope, $q, $injector) { var errorCode = rejectionErrorCode(rejection); if (rejection.refreshTokenPending || (errorCode && errorCode === getTypes().serverErrorCode.jwtTokenExpired)) { return refreshTokenAndRetry(rejection); - } else { + } else if (errorCode !== getTypes().serverErrorCode.credentialsExpired) { unhandled = true; } } else if (rejection.status === 403) { diff --git a/ui/src/app/help/help-links.constant.js b/ui/src/app/help/help-links.constant.js index dbe96377e6..9619d71475 100644 --- a/ui/src/app/help/help-links.constant.js +++ b/ui/src/app/help/help-links.constant.js @@ -56,6 +56,7 @@ export default angular.module('thingsboard.help', []) { linksMap: { outgoingMailSettings: helpBaseUrl + "/docs/user-guide/ui/mail-settings", + securitySettings: helpBaseUrl + "/docs/user-guide/ui/security-settings", ruleEngine: helpBaseUrl + "/docs/user-guide/rule-engine-2-0/overview/", ruleNodeCheckRelation: helpBaseUrl + "/docs/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node", ruleNodeJsFilter: helpBaseUrl + "/docs/user-guide/rule-engine-2-0/filter-nodes/#script-filter-node", diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 9e2d3041e3..9f4a6ceb87 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -84,7 +84,22 @@ "timeout-required": "Timeout is required.", "timeout-invalid": "That doesn't look like a valid timeout.", "enable-tls": "Enable TLS", - "send-test-mail": "Send test mail" + "send-test-mail": "Send test mail", + "security-settings": "Security settings", + "password-policy": "Password policy", + "minimum-password-length": "Minimum password length", + "minimum-password-length-required": "Minimum password length is required", + "minimum-password-length-range": "Minimum password length should be in a range from 5 to 50", + "minimum-uppercase-letters": "Minimum number of uppercase letters", + "minimum-uppercase-letters-range": "Minimum number of uppercase letters can't be negative", + "minimum-lowercase-letters": "Minimum number of lowercase letters", + "minimum-lowercase-letters-range": "Minimum number of lowercase letters can't be negative", + "minimum-digits": "Minimum number of digits", + "minimum-digits-range": "Minimum number of digits can't be negative", + "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" }, "alarm": { "alarm": "Alarm", @@ -306,6 +321,8 @@ "type-relations-delete": "All relation deleted", "type-alarm-ack": "Acknowledged", "type-alarm-clear": "Cleared", + "type-login": "Login", + "type-logout": "Logout", "status-success": "Success", "status-failure": "Failure", "audit-log-details": "Audit log details", @@ -1183,6 +1200,7 @@ "remember-me": "Remember me", "forgot-password": "Forgot Password?", "password-reset": "Password reset", + "expired-password-reset-message": "Your credentials has been expired! Please create new password.", "new-password": "New password", "new-password-again": "New password again", "password-link-sent-message": "Password reset link was successfully sent!", diff --git a/ui/src/app/login/login.controller.js b/ui/src/app/login/login.controller.js index 2da852c392..178d5dff8e 100644 --- a/ui/src/app/login/login.controller.js +++ b/ui/src/app/login/login.controller.js @@ -20,7 +20,7 @@ import logoSvg from '../../svg/logo_title_white.svg'; /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function LoginController(toast, loginService, userService/*, $rootScope, $log, $translate*/) { +export default function LoginController(toast, loginService, userService, types, $state/*, $rootScope, $log, $translate*/) { var vm = this; vm.logoSvg = logoSvg; @@ -37,7 +37,7 @@ export default function LoginController(toast, loginService, userService/*, $roo var token = response.data.token; var refreshToken = response.data.refreshToken; userService.setUserFromJwtToken(token, refreshToken, true); - }, function fail(/*response*/) { + }, function fail(response) { /*if (response && response.data && response.data.message) { toast.showError(response.data.message); } else if (response && response.statusText) { @@ -45,6 +45,11 @@ export default function LoginController(toast, loginService, userService/*, $roo } else { toast.showError($translate.instant('error.unknown-error')); }*/ + if (response && response.data && response.data.errorCode) { + if (response.data.errorCode === types.serverErrorCode.credentialsExpired) { + $state.go('login.resetExpiredPassword', {resetToken: response.data.resetToken}); + } + } }); } diff --git a/ui/src/app/login/login.routes.js b/ui/src/app/login/login.routes.js index 6fa18117a8..66a51188ac 100644 --- a/ui/src/app/login/login.routes.js +++ b/ui/src/app/login/login.routes.js @@ -63,6 +63,20 @@ export default function LoginRoutes($stateProvider) { data: { pageTitle: 'login.reset-password' } + }).state('login.resetExpiredPassword', { + url: '/resetExpiredPassword?resetToken', + module: 'public', + views: { + "@": { + controller: 'ResetPasswordController', + controllerAs: 'vm', + templateUrl: resetPasswordTemplate + } + }, + data: { + expiredPassword: true, + pageTitle: 'login.reset-password' + } }).state('login.createPassword', { url: '/createPassword?activateToken', module: 'public', diff --git a/ui/src/app/login/reset-password.controller.js b/ui/src/app/login/reset-password.controller.js index e1ad29c467..fc4991fd6f 100644 --- a/ui/src/app/login/reset-password.controller.js +++ b/ui/src/app/login/reset-password.controller.js @@ -14,7 +14,7 @@ * limitations under the License. */ /*@ngInject*/ -export default function ResetPasswordController($stateParams, $translate, toast, loginService, userService) { +export default function ResetPasswordController($stateParams, $state, $translate, toast, loginService, userService) { var vm = this; vm.newPassword = ''; @@ -22,6 +22,8 @@ export default function ResetPasswordController($stateParams, $translate, toast, vm.resetPassword = resetPassword; + vm.isExpiredPassword = $state.$current.data.expiredPassword === true; + function resetPassword() { if (vm.newPassword !== vm.newPassword2) { toast.showError($translate.instant('login.passwords-mismatch-error')); diff --git a/ui/src/app/login/reset-password.tpl.html b/ui/src/app/login/reset-password.tpl.html index f34dcdc831..aff4e373d4 100644 --- a/ui/src/app/login/reset-password.tpl.html +++ b/ui/src/app/login/reset-password.tpl.html @@ -20,6 +20,7 @@ login.password-reset + login.expired-password-reset-message