From 8779c629e7d2fa49ddb92d13bfb7e60ea58c9bb4 Mon Sep 17 00:00:00 2001 From: livk Date: Wed, 31 Jan 2024 14:17:39 +0800 Subject: [PATCH 01/34] fix: fixed the error that the file could not be found after packing --- .../mail/DefaultTbMailConfigTemplateService.java | 2 +- .../java/org/thingsboard/common/util/JacksonUtil.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java index f45f949fca..66207b5c39 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java @@ -32,7 +32,7 @@ public class DefaultTbMailConfigTemplateService implements TbMailConfigTemplateS @PostConstruct private void postConstruct() throws IOException { - mailConfigTemplates = JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getFile()); + mailConfigTemplates = JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getInputStream()); } @Override diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index 53c3860dde..46c76b5a61 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.kv.KvEntry; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.io.Reader; import java.io.Writer; import java.util.Arrays; @@ -182,6 +183,15 @@ public class JacksonUtil { } } + public static JsonNode toJsonNode(InputStream value) { + try { + return value != null ? OBJECT_MAPPER.readTree(value) : null; + } catch (IOException e) { + throw new IllegalArgumentException("The given InputStream value: " + + value + " cannot be transformed to a JsonNode", e); + } + } + public static ObjectNode newObjectNode() { return newObjectNode(OBJECT_MAPPER); } From 976e1e1e1f463f6474a42aa84b22251bf9918d51 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 24 Jul 2024 13:22:17 +0300 Subject: [PATCH 02/34] Implement TTL for password reset and user activation links; refactoring and improvements --- .../main/data/upgrade/3.7.0/schema_update.sql | 16 ++ .../server/controller/AdminController.java | 6 +- .../server/controller/AuthController.java | 61 ++++--- .../server/controller/BaseController.java | 2 +- .../server/controller/UserController.java | 64 ++++---- .../entitiy/user/DefaultUserService.java | 3 +- .../secret/MobileAppSecretServiceImpl.java | 10 +- .../auth/rest/RestAuthenticationProvider.java | 8 +- .../system/DefaultSystemSecurityService.java | 84 ++-------- .../system/SystemSecurityService.java | 9 +- .../server/controller/AuthControllerTest.java | 151 +++++++++++++----- .../server/dao/user/UserService.java | 6 +- .../common/data/security/UserCredentials.java | 102 ++---------- .../data/security/model/SecuritySettings.java | 25 ++- .../server/dao/model/ModelConstants.java | 2 + .../dao/model/sql/UserCredentialsEntity.java | 17 +- .../DefaultSecuritySettingsService.java | 81 ++++++++++ .../dao/settings/SecuritySettingsService.java | 26 +++ .../server/dao/user/UserServiceImpl.java | 29 +++- .../main/resources/sql/schema-entities.sql | 2 + .../sql/user/JpaUserCredentialsDaoTest.java | 2 + 21 files changed, 417 insertions(+), 289 deletions(-) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java diff --git a/application/src/main/data/upgrade/3.7.0/schema_update.sql b/application/src/main/data/upgrade/3.7.0/schema_update.sql index 6b87dc6dde..a7e4aaeeef 100644 --- a/application/src/main/data/upgrade/3.7.0/schema_update.sql +++ b/application/src/main/data/upgrade/3.7.0/schema_update.sql @@ -14,3 +14,19 @@ -- limitations under the License. -- +-- USER CREDENTIALS UPDATE START + +ALTER TABLE user_credentials ADD COLUMN IF NOT EXISTS activate_token_exp_time BIGINT; +-- Setting 24-hour TTL for existing activation tokens +UPDATE user_credentials SET activate_token_exp_time = cast(extract(EPOCH FROM NOW()) * 1000 AS BIGINT) + 86400000 + WHERE activate_token IS NOT NULL AND activate_token_exp_time IS NULL; + +ALTER TABLE user_credentials ADD COLUMN IF NOT EXISTS reset_token_exp_time BIGINT; +-- Setting 24-hour TTL for existing password reset tokens +UPDATE user_credentials SET reset_token_exp_time = cast(extract(EPOCH FROM NOW()) * 1000 AS BIGINT) + 86400000 + WHERE reset_token IS NOT NULL AND reset_token_exp_time IS NULL; + +UPDATE admin_settings SET json_value = (json_value::jsonb || '{"userActivationTokenTtl":24,"passwordResetTokenTtl":24}'::jsonb)::varchar + WHERE key = 'securitySettings'; + +-- USER CREDENTIALS UPDATE END 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 65903a4dec..46eeefd951 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -73,6 +73,7 @@ import org.thingsboard.server.common.data.sync.vc.VcUtils; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.security.auth.oauth2.CookieUtils; @@ -109,6 +110,7 @@ public class AdminController extends BaseController { private final SmsService smsService; private final AdminSettingsService adminSettingsService; private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; private final JwtSettingsService jwtSettingsService; private final JwtTokenFactory tokenFactory; private final EntitiesVersionControlService versionControlService; @@ -167,7 +169,7 @@ public class AdminController extends BaseController { @ResponseBody public SecuritySettings getSecuritySettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); - return checkNotNull(systemSecurityService.getSecuritySettings()); + return checkNotNull(securitySettingsService.getSecuritySettings()); } @ApiOperation(value = "Update Security Settings (saveSecuritySettings)", @@ -179,7 +181,7 @@ public class AdminController extends BaseController { @Parameter(description = "A JSON value representing the Security Settings.") @RequestBody SecuritySettings securitySettings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); - securitySettings = checkNotNull(systemSecurityService.saveSecuritySettings(securitySettings)); + securitySettings = checkNotNull(securitySettingsService.saveSecuritySettings(securitySettings)); return securitySettings; } 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 f95dfcb648..86301cfb6a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -26,9 +26,10 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -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.ResponseStatus; @@ -48,6 +49,7 @@ import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.model.ActivateUserRequest; @@ -75,6 +77,7 @@ public class AuthController extends BaseController { private final JwtTokenFactory tokenFactory; private final MailService mailService; private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; private final RateLimitService rateLimitService; private final ApplicationEventPublisher eventPublisher; @@ -82,7 +85,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Get current User (getUser)", notes = "Get the information about the User which credentials are used to perform this REST API call.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/auth/user", method = RequestMethod.GET) + @GetMapping(value = "/auth/user") public @ResponseBody User getUser() throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); @@ -92,7 +95,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Logout (logout)", notes = "Special API call to record the 'logout' of the user to the Audit Logs. Since platform uses [JWT](https://jwt.io/), the actual logout is the procedure of clearing the [JWT](https://jwt.io/) token on the client side. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/auth/logout", method = RequestMethod.POST) + @PostMapping(value = "/auth/logout") @ResponseStatus(value = HttpStatus.OK) public void logout(HttpServletRequest request) throws ThingsboardException { logLogoutAction(request); @@ -101,8 +104,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Change password for current User (changePassword)", notes = "Change the password for the User which credentials are used to perform this REST API call. Be aware that previously generated [JWT](https://jwt.io/) tokens will be still valid until they expire.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) + @PostMapping(value = "/auth/changePassword") public JwtPair changePassword(@Parameter(description = "Change Password Request") @RequestBody ChangePasswordRequest changePasswordRequest) throws ThingsboardException { String currentPassword = changePasswordRequest.getCurrentPassword(); @@ -125,11 +127,9 @@ public class AuthController extends BaseController { @ApiOperation(value = "Get the current User password policy (getUserPasswordPolicy)", notes = "API call to get the password policy for the password validation form(s).") - @RequestMapping(value = "/noauth/userPasswordPolicy", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/noauth/userPasswordPolicy") public UserPasswordPolicy getUserPasswordPolicy() throws ThingsboardException { - SecuritySettings securitySettings = - checkNotNull(systemSecurityService.getSecuritySettings()); + SecuritySettings securitySettings = checkNotNull(securitySettingsService.getSecuritySettings()); return securitySettings.getPasswordPolicy(); } @@ -137,14 +137,14 @@ public class AuthController extends BaseController { notes = "Checks the activation token and forwards user to 'Create Password' page. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'.") - @RequestMapping(value = "/noauth/activate", params = {"activateToken"}, method = RequestMethod.GET) + @GetMapping(value = "/noauth/activate", params = {"activateToken"}) public ResponseEntity checkActivateToken( @Parameter(description = "The activate token string.") @RequestParam(value = "activateToken") String activateToken) { HttpHeaders headers = new HttpHeaders(); HttpStatus responseStatus; UserCredentials userCredentials = userService.findUserCredentialsByActivateToken(TenantId.SYS_TENANT_ID, activateToken); - if (userCredentials != null) { + if (userCredentials != null && !userCredentials.isActivationTokenExpired()) { String createURI = "/login/createPassword"; try { URI location = new URI(createURI + "?activateToken=" + activateToken); @@ -163,8 +163,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Request reset password email (requestResetPasswordByEmail)", notes = "Request to send the reset password email if the user with specified email address is present in the database. " + "Always return '200 OK' status for security purposes.") - @RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) + @PostMapping(value = "/noauth/resetPasswordByEmail") public void requestResetPasswordByEmail( @Parameter(description = "The JSON object representing the reset password email request.") @RequestBody ResetPasswordEmailRequest resetPasswordByEmailRequest, @@ -187,7 +186,7 @@ public class AuthController extends BaseController { notes = "Checks the password reset token and forwards user to 'Reset Password' page. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'.") - @RequestMapping(value = "/noauth/resetPassword", params = {"resetToken"}, method = RequestMethod.GET) + @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"}) public ResponseEntity checkResetToken( @Parameter(description = "The reset token string.") @RequestParam(value = "resetToken") String resetToken) { @@ -196,7 +195,7 @@ public class AuthController extends BaseController { String resetURI = "/login/resetPassword"; UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); - if (userCredentials != null) { + if (userCredentials != null && !userCredentials.isResetTokenExpired()) { if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); } @@ -220,15 +219,12 @@ public class AuthController extends BaseController { "The response already contains the [JWT](https://jwt.io) activation and refresh tokens, " + "to simplify the user activation flow and avoid asking user to input password again after activation. " + "If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " + - "If token is not valid, returns '404 Bad Request'.") - @RequestMapping(value = "/noauth/activate", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) - @ResponseBody - public JwtPair activateUser( - @Parameter(description = "Activate user request.") - @RequestBody ActivateUserRequest activateRequest, - @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, - HttpServletRequest request) throws ThingsboardException { + "If token is not valid, returns '400 Bad Request'.") + @PostMapping(value = "/noauth/activate") + public JwtPair activateUser(@Parameter(description = "Activate user request.") + @RequestBody ActivateUserRequest activateRequest, + @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, + HttpServletRequest request) { String activateToken = activateRequest.getActivateToken(); String password = activateRequest.getPassword(); systemSecurityService.validatePassword(password, null); @@ -258,18 +254,18 @@ public class AuthController extends BaseController { @ApiOperation(value = "Reset password (resetPassword)", notes = "Checks the password reset token and updates the password. " + "If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " + - "If token is not valid, returns '404 Bad Request'.") - @RequestMapping(value = "/noauth/resetPassword", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) - @ResponseBody - public JwtPair resetPassword( - @Parameter(description = "Reset password request.") - @RequestBody ResetPasswordRequest resetPasswordRequest, - HttpServletRequest request) throws ThingsboardException { + "If token is not valid, returns '400 Bad Request'.") + @PostMapping(value = "/noauth/resetPassword") + public JwtPair resetPassword(@Parameter(description = "Reset password request.") + @RequestBody ResetPasswordRequest resetPasswordRequest, + HttpServletRequest request) throws ThingsboardException { String resetToken = resetPasswordRequest.getResetToken(); String password = resetPasswordRequest.getPassword(); UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); if (userCredentials != null) { + if (userCredentials.isResetTokenExpired()) { + throw new ThingsboardException("Password reset token expired", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } systemSecurityService.validatePassword(password, userCredentials); if (passwordEncoder.matches(password, userCredentials.getPassword())) { throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); @@ -277,6 +273,7 @@ public class AuthController extends BaseController { String encodedPassword = passwordEncoder.encode(password); userCredentials.setPassword(encodedPassword); userCredentials.setResetToken(null); + userCredentials.setResetTokenExpTime(null); 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()); diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index f9d1fe7a2f..da956d4fa5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -178,7 +178,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @TbCoreComponent public abstract class BaseController { - private final Logger log = org.slf4j.LoggerFactory.getLogger(getClass()); + protected final Logger log = org.slf4j.LoggerFactory.getLogger(getClass()); /*Swagger UI description*/ 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 1772a64772..c76958dabe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -21,7 +21,6 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; @@ -130,12 +129,8 @@ public class UserController extends BaseController { private final SystemSecurityService systemSecurityService; private final ApplicationEventPublisher eventPublisher; private final TbUserService tbUserService; - - @Autowired - private EntityQueryService entityQueryService; - - @Autowired - private EntityService entityService; + private final EntityQueryService entityQueryService; + private final EntityService entityService; @ApiOperation(value = "Get User (getUserById)", notes = "Fetch the User object based on the provided User Id. " + @@ -212,7 +207,7 @@ public class UserController extends BaseController { public User saveUser( @Parameter(description = "A JSON value representing the User.", required = true) @RequestBody User user, - @Parameter(description = "Send activation email (or use activation link)" , schema = @Schema(defaultValue = "true")) + @Parameter(description = "Send activation email (or use activation link)", schema = @Schema(defaultValue = "true")) @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, HttpServletRequest request) throws ThingsboardException { if (!Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { user.setTenantId(getCurrentUser().getTenantId()); @@ -231,19 +226,10 @@ public class UserController extends BaseController { @RequestParam(value = "email") String email, HttpServletRequest request) throws ThingsboardException { User user = checkNotNull(userService.findUserByEmail(getCurrentUser().getTenantId(), email)); + accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, user.getId(), user); - accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, - user.getId(), user); - - UserCredentials userCredentials = userService.findUserCredentialsByUserId(getCurrentUser().getTenantId(), user.getId()); - if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { - String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request); - String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, - userCredentials.getActivateToken()); - mailService.sendActivationEmail(activateUrl, email); - } else { - throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - } + String activationLink = getActivationLink(user.getId(), request); + mailService.sendActivationEmail(activationLink, email); } @ApiOperation(value = "Get the activation link (getActivationLink)", @@ -252,23 +238,13 @@ public class UserController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(value = "/user/{userId}/activationLink", method = RequestMethod.GET, produces = "text/plain") @ResponseBody - public String getActivationLink( - @Parameter(description = USER_ID_PARAM_DESCRIPTION) - @PathVariable(USER_ID) String strUserId, - HttpServletRequest request) throws ThingsboardException { + public String getActivationLink(@Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + HttpServletRequest request) throws ThingsboardException { checkParameter(USER_ID, strUserId); UserId userId = new UserId(toUUID(strUserId)); - User user = checkUserId(userId, Operation.READ); - SecurityUser authUser = getCurrentUser(); - UserCredentials userCredentials = userService.findUserCredentialsByUserId(authUser.getTenantId(), user.getId()); - if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { - String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request); - String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, - userCredentials.getActivateToken()); - return activateUrl; - } else { - throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - } + checkUserId(userId, Operation.READ); + return getActivationLink(userId, request); } @ApiOperation(value = "Delete User (deleteUser)", @@ -411,7 +387,7 @@ public class UserController extends BaseController { public void setUserCredentialsEnabled( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId, - @Parameter(description = "Enable (\"true\") or disable (\"false\") the credentials." , schema = @Schema(defaultValue = "true")) + @Parameter(description = "Enable (\"true\") or disable (\"false\") the credentials.", schema = @Schema(defaultValue = "true")) @RequestParam(required = false, defaultValue = "true") boolean userCredentialsEnabled) throws ThingsboardException { checkParameter(USER_ID, strUserId); UserId userId = new UserId(toUUID(strUserId)); @@ -610,6 +586,22 @@ public class UserController extends BaseController { userService.removeMobileSession(user.getTenantId(), mobileToken); } + private String getActivationLink(UserId userId, HttpServletRequest request) throws ThingsboardException { + TenantId tenantId = getTenantId(); + UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); + if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { + if (userCredentials.isActivationTokenExpired()) { + userCredentials = userService.generateUserActivationToken(userCredentials); + userCredentials = userService.saveUserCredentials(tenantId, userCredentials); + log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userId); + } + String baseUrl = systemSecurityService.getBaseUrl(tenantId, getCurrentUser().getCustomerId(), request); + return String.format(ACTIVATE_URL_PATTERN, baseUrl, userCredentials.getActivateToken()); + } else { + throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + private void checkNotReserved(String strType, UserSettingsType type) throws ThingsboardException { if (type.isReserved()) { throw new ThingsboardException("Settings with type: " + strType + " are reserved for internal use!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java index 68595bed2e..1d5fbba859 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java @@ -56,8 +56,7 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse if (sendEmail) { UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, savedUser.getId()); String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request); - String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, - userCredentials.getActivateToken()); + String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, userCredentials.getActivateToken()); String email = savedUser.getEmail(); try { mailService.sendActivationEmail(activateUrl, email); diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java index bef31622ff..73496ca1a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java @@ -25,11 +25,12 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.dao.entity.AbstractCachedService; +import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; -import org.thingsboard.server.service.security.system.SystemSecurityService; -import static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_MOBILE_SECRET_KEY_LENGTH; +import static org.thingsboard.server.dao.settings.DefaultSecuritySettingsService.DEFAULT_MOBILE_SECRET_KEY_LENGTH; + @Service @Slf4j @@ -37,12 +38,12 @@ import static org.thingsboard.server.service.security.system.DefaultSystemSecuri public class MobileAppSecretServiceImpl extends AbstractCachedService implements MobileAppSecretService { private final JwtTokenFactory tokenFactory; - private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; @Override public String generateMobileAppSecret(SecurityUser securityUser) { log.trace("Executing generateSecret for user [{}]", securityUser.getId()); - Integer mobileSecretKeyLength = systemSecurityService.getSecuritySettings().getMobileSecretKeyLength(); + Integer mobileSecretKeyLength = securitySettingsService.getSecuritySettings().getMobileSecretKeyLength(); String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength); cache.put(secret, tokenFactory.createTokenPair(securityUser)); return secret; @@ -63,4 +64,5 @@ public class MobileAppSecretServiceImpl extends AbstractCachedService 0) { if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); @@ -153,7 +98,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) { if ((userCredentials.getCreatedTime() + TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) @@ -181,7 +126,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { if (maxVerificationFailures != null && maxVerificationFailures > 0 && failedVerificationAttempts >= maxVerificationFailures) { userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); lockAccount(userId, securityUser.getEmail(), securitySettings.getUserLockoutNotificationEmail(), maxVerificationFailures); throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); } @@ -200,7 +145,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Override public void validatePassword(String password, UserCredentials userCredentials) throws DataValidationException { - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); validatePasswordByPolicy(password, passwordPolicy); @@ -330,4 +275,5 @@ public class DefaultSystemSecurityService implements SystemSecurityService { 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 index 06e5cece81..8c1ac733ee 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 @@ -15,26 +15,20 @@ */ package org.thingsboard.server.service.security.system; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.AuthenticationException; 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.TenantId; import org.thingsboard.server.common.data.security.UserCredentials; -import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.service.security.model.SecurityUser; -import jakarta.servlet.http.HttpServletRequest; - public interface SystemSecurityService { - SecuritySettings getSecuritySettings(); - - SecuritySettings saveSecuritySettings(SecuritySettings securitySettings); - void validatePasswordByPolicy(String password, UserPasswordPolicy passwordPolicy); void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException; @@ -48,4 +42,5 @@ public interface SystemSecurityService { void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e); void logLoginAction(User user, Object authenticationDetails, ActionType actionType, String provider, Exception e); + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index 1cf389a93d..f3c0beab8f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -16,20 +16,28 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; +import org.assertj.core.data.Offset; import org.junit.After; import org.junit.Test; import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.HttpHeaders; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.user.UserCredentialsDao; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.model.ChangePasswordRequest; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -39,55 +47,55 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class AuthControllerTest extends AbstractControllerTest { + @SpyBean + private UserCredentialsDao userCredentialsDao; + @After public void tearDown() throws Exception { loginSysAdmin(); - SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); - - securitySettings.getPasswordPolicy().setMaximumLength(72); - securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false); - - doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); + updateSecuritySettings(securitySettings -> { + securitySettings.getPasswordPolicy().setMaximumLength(72); + securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false); + }); } @Test public void testGetUser() throws Exception { - doGet("/api/auth/user") - .andExpect(status().isUnauthorized()); - + .andExpect(status().isUnauthorized()); + loginSysAdmin(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); - + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); + loginTenantAdmin(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.TENANT_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(TENANT_ADMIN_EMAIL))); - + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.TENANT_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(TENANT_ADMIN_EMAIL))); + loginCustomerUser(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.CUSTOMER_USER.name()))) - .andExpect(jsonPath("$.email",is(CUSTOMER_USER_EMAIL))); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.CUSTOMER_USER.name()))) + .andExpect(jsonPath("$.email", is(CUSTOMER_USER_EMAIL))); } - + @Test public void testLoginLogout() throws Exception { loginSysAdmin(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); TimeUnit.SECONDS.sleep(1); //We need to make sure that event for invalidating token was successfully processed logout(); doGet("/api/auth/user") - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()); resetTokens(); } @@ -97,14 +105,14 @@ public class AuthControllerTest extends AbstractControllerTest { loginSysAdmin(); doGet("/api/auth/user") .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); refreshToken(); doGet("/api/auth/user") .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); } @Test @@ -131,10 +139,10 @@ public class AuthControllerTest extends AbstractControllerTest { loginUser(TENANT_ADMIN_EMAIL, newPassword); loginSysAdmin(); - SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); - securitySettings.getPasswordPolicy().setMaximumLength(15); - securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true); - doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); + updateSecuritySettings(securitySettings -> { + securitySettings.getPasswordPolicy().setMaximumLength(15); + securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true); + }); //try to login with user password that is not valid after security settings was updated doPost("/api/auth/login", new LoginRequest(TENANT_ADMIN_EMAIL, newPassword)) @@ -142,6 +150,7 @@ public class AuthControllerTest extends AbstractControllerTest { .andExpect(jsonPath("$.message", is("The entered password violates our policies. If this is your real password, please reset it."))); } + @Test public void testShouldNotResetPasswordToTooLongValue() throws Exception { loginTenantAdmin(); @@ -163,9 +172,72 @@ public class AuthControllerTest extends AbstractControllerTest { Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString()); doPost("/api/noauth/resetPassword", resetPasswordRequest) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message", - is("Password must be no more than 72 characters in length."))); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", + is("Password must be no more than 72 characters in length."))); + } + + @Test + public void testPasswordResetLinkTtl() throws Exception { + loginSysAdmin(); + int ttl = 24; + updateSecuritySettings(securitySettings -> { + securitySettings.setPasswordResetTokenTtl(ttl); + }); + doPost("/api/noauth/resetPasswordByEmail", JacksonUtil.newObjectNode() + .put("email", TENANT_ADMIN_EMAIL)).andExpect(status().isOk()); + + UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, tenantAdminUserId.getId()); + assertThat(userCredentials.getResetTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); + userCredentials.setResetTokenExpTime(System.currentTimeMillis() - 1); + userCredentialsDao.save(tenantId, userCredentials); + + doGet("/api/noauth/resetPassword?resetToken={resetToken}", this.currentResetPasswordToken) + .andExpect(status().isConflict()); + JsonNode resetPasswordRequest = JacksonUtil.newObjectNode() + .put("resetToken", this.currentResetPasswordToken) + .put("password", "wefwefe"); + doPost("/api/noauth/resetPassword", resetPasswordRequest).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", is("Password reset token expired"))); + } + + @Test + public void testActivationLinkTtl() throws Exception { + loginSysAdmin(); + int ttl = 24; + updateSecuritySettings(securitySettings -> { + securitySettings.setUserActivationTokenTtl(ttl); + }); + + loginTenantAdmin(); + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail("tenant-admin-2@thingsboard.org"); + user = doPost("/api/user", user, User.class); + + UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); + assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); + String initialActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); + String initialActivationToken = StringUtils.substringAfterLast(initialActivationLink, "activateToken="); + + userCredentials.setActivateTokenExpTime(System.currentTimeMillis() - 1); + userCredentialsDao.save(tenantId, userCredentials); + doGet("/api/noauth/activate?activateToken={activateToken}", initialActivationToken) + .andExpect(status().isConflict()); + doPost("/api/noauth/activate", JacksonUtil.newObjectNode() + .put("activateToken", initialActivationToken) + .put("password", "wefewe")).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", is("Activation token expired"))); + + String regeneratedActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); + String regeneratedActivationToken = StringUtils.substringAfterLast(regeneratedActivationLink, "activateToken="); + assertThat(regeneratedActivationToken).isNotEqualTo(initialActivationLink); + userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); + assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); + + doPost("/api/noauth/activate", JacksonUtil.newObjectNode() + .put("activateToken", regeneratedActivationToken) + .put("password", "wefewe")).andExpect(status().isOk()); } @Test @@ -173,4 +245,11 @@ public class AuthControllerTest extends AbstractControllerTest { doGet("/login").andExpect(status().isOk()); doGet("/home").andExpect(status().isOk()); } + + private void updateSecuritySettings(Consumer updater) throws Exception { + SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); + updater.accept(securitySettings); + doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); + } + } 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 c3ecf80268..8f22812cfc 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 @@ -17,12 +17,12 @@ package org.thingsboard.server.dao.user; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.mobile.MobileSessionInfo; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.mobile.MobileSessionInfo; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.UserCredentials; @@ -59,6 +59,10 @@ public interface UserService extends EntityDaoService { UserCredentials requestExpiredPasswordReset(TenantId tenantId, UserCredentialsId userCredentialsId); + UserCredentials generatePasswordResetToken(UserCredentials userCredentials); + + UserCredentials generateUserActivationToken(UserCredentials userCredentials); + UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); void deleteUser(TenantId tenantId, User user); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java index ed036e482a..06645feec3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java @@ -16,41 +16,31 @@ package org.thingsboard.server.common.data.security; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.BaseData; +import lombok.ToString; +import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.validation.NoXss; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.getJson; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.setJson; +import java.io.Serial; +@Data @EqualsAndHashCode(callSuper = true) -public class UserCredentials extends BaseData { +@ToString(callSuper = true) +public class UserCredentials extends BaseDataWithAdditionalInfo { + @Serial private static final long serialVersionUID = -2108436378880529163L; private UserId userId; private boolean enabled; private String password; private String activateToken; + private Long activateTokenExpTime; private String resetToken; + private Long resetTokenExpTime; - @NoXss - private transient JsonNode additionalInfo; - - @JsonIgnore - private byte[] additionalInfoBytes; - - public JsonNode getAdditionalInfo() { - return getJson(() -> additionalInfo, () -> additionalInfoBytes); - } - - public void setAdditionalInfo(JsonNode settings) { - setJson(settings, json -> this.additionalInfo = json, bytes -> this.additionalInfoBytes = bytes); - } - public UserCredentials() { super(); } @@ -59,75 +49,15 @@ public class UserCredentials extends BaseData { super(id); } - public UserCredentials(UserCredentials userCredentials) { - super(userCredentials); - this.userId = userCredentials.getUserId(); - this.password = userCredentials.getPassword(); - this.enabled = userCredentials.isEnabled(); - this.activateToken = userCredentials.getActivateToken(); - this.resetToken = userCredentials.getResetToken(); - setAdditionalInfo(userCredentials.getAdditionalInfo()); - } - - public UserId getUserId() { - return userId; - } - - public void setUserId(UserId userId) { - this.userId = userId; - } - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public String getPassword() { - return password; - } - public void setPassword(String password) { - this.password = password; - } - - public String getActivateToken() { - return activateToken; - } - - public void setActivateToken(String activateToken) { - this.activateToken = activateToken; - } - - public String getResetToken() { - return resetToken; - } - - public void setResetToken(String resetToken) { - this.resetToken = resetToken; + @JsonIgnore + public boolean isActivationTokenExpired() { + return activateTokenExpTime == null || System.currentTimeMillis() > activateTokenExpTime; } - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("UserCredentials [userId="); - builder.append(userId); - builder.append(", enabled="); - builder.append(enabled); - builder.append(", password="); - builder.append(password); - builder.append(", activateToken="); - builder.append(activateToken); - builder.append(", resetToken="); - builder.append(resetToken); - builder.append(", createdTime="); - builder.append(createdTime); - builder.append(", id="); - builder.append(id); - builder.append("]"); - return builder.toString(); + @JsonIgnore + public boolean isResetTokenExpired() { + return resetTokenExpTime == null || System.currentTimeMillis() > resetTokenExpTime; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java index aa4d303440..70e1853ffd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java @@ -16,22 +16,39 @@ package org.thingsboard.server.common.data.security.model; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.Data; +import java.io.Serial; import java.io.Serializable; @Schema @Data public class SecuritySettings implements Serializable { + @Serial private static final long serialVersionUID = -1307613974597312465L; - @Schema(description = "The user password policy object." ) + @Schema(description = "The user password policy object.") private UserPasswordPolicy passwordPolicy; - @Schema(description = "Maximum number of failed login attempts allowed before user account is locked." ) + + @Schema(description = "Maximum number of failed login attempts allowed before user account is locked.") private Integer maxFailedLoginAttempts; - @Schema(description = "Email to use for notifications about locked users." ) + + @Schema(description = "Email to use for notifications about locked users.") private String userLockoutNotificationEmail; - @Schema(description = "Mobile secret key length" ) + + @Schema(description = "Mobile secret key length") private Integer mobileSecretKeyLength; + + @NotNull @Min(1) @Max(24) + @Schema(description = "TTL in hours for user activation link", minimum = "1", maximum = "24", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer userActivationTokenTtl; + + @NotNull @Min(1) @Max(24) + @Schema(description = "TTL in hours for password reset link", minimum = "1", maximum = "24", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer passwordResetTokenTtl; + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index fb77ad6987..4194112bb9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -77,7 +77,9 @@ public class ModelConstants { public static final String USER_CREDENTIALS_ENABLED_PROPERTY = "enabled"; public static final String USER_CREDENTIALS_PASSWORD_PROPERTY = "password"; //NOSONAR, the constant used to identify password column name (not password value itself) public static final String USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY = "activate_token"; + public static final String USER_CREDENTIALS_ACTIVATE_TOKEN_EXP_TIME_PROPERTY = "activate_token_exp_time"; public static final String USER_CREDENTIALS_RESET_TOKEN_PROPERTY = "reset_token"; + public static final String USER_CREDENTIALS_RESET_TOKEN_EXP_TIME_PROPERTY = "reset_token_exp_time"; public static final String USER_CREDENTIALS_ADDITIONAL_PROPERTY = "additional_info"; /** diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java index e7907921bf..edd1536a04 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java @@ -16,7 +16,10 @@ package org.thingsboard.server.dao.model.sql; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.UserCredentialsId; @@ -25,10 +28,6 @@ import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.util.UUID; @@ -51,9 +50,15 @@ public final class UserCredentialsEntity extends BaseSqlEntity @Column(name = ModelConstants.USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY, unique = true) private String activateToken; + @Column(name = ModelConstants.USER_CREDENTIALS_ACTIVATE_TOKEN_EXP_TIME_PROPERTY) + private Long activateTokenExpTime; + @Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_PROPERTY, unique = true) private String resetToken; + @Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_EXP_TIME_PROPERTY) + private Long resetTokenExpTime; + @Convert(converter = JsonConverter.class) @Column(name = ModelConstants.USER_CREDENTIALS_ADDITIONAL_PROPERTY) private JsonNode additionalInfo; @@ -73,7 +78,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity this.enabled = userCredentials.isEnabled(); this.password = userCredentials.getPassword(); this.activateToken = userCredentials.getActivateToken(); + this.activateTokenExpTime = userCredentials.getActivateTokenExpTime(); this.resetToken = userCredentials.getResetToken(); + this.resetTokenExpTime = userCredentials.getResetTokenExpTime(); this.additionalInfo = userCredentials.getAdditionalInfo(); } @@ -87,7 +94,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity userCredentials.setEnabled(enabled); userCredentials.setPassword(password); userCredentials.setActivateToken(activateToken); + userCredentials.setActivateTokenExpTime(activateTokenExpTime); userCredentials.setResetToken(resetToken); + userCredentials.setResetTokenExpTime(resetTokenExpTime); userCredentials.setAdditionalInfo(additionalInfo); return userCredentials; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java new file mode 100644 index 0000000000..18c2f68c0a --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java @@ -0,0 +1,81 @@ +/** + * Copyright © 2016-2024 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.dao.settings; + +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.dao.service.ConstraintValidator; + +import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE; + +@Service +@RequiredArgsConstructor +public class DefaultSecuritySettingsService implements SecuritySettingsService { + + private final AdminSettingsService adminSettingsService; + + public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64; + + @Cacheable(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'") + @Override + public SecuritySettings getSecuritySettings() { + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "securitySettings"); + SecuritySettings securitySettings; + if (adminSettings != null) { + try { + securitySettings = JacksonUtil.convertValue(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); + securitySettings.getPasswordPolicy().setMaximumLength(72); + securitySettings.setMobileSecretKeyLength(DEFAULT_MOBILE_SECRET_KEY_LENGTH); + securitySettings.setPasswordResetTokenTtl(24); + securitySettings.setUserActivationTokenTtl(24); + } + return securitySettings; + } + + @CacheEvict(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'") + @Override + public SecuritySettings saveSecuritySettings(SecuritySettings securitySettings) { + ConstraintValidator.validateFields(securitySettings); + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "securitySettings"); + if (adminSettings == null) { + adminSettings = new AdminSettings(); + adminSettings.setTenantId(TenantId.SYS_TENANT_ID); + adminSettings.setKey("securitySettings"); + } + adminSettings.setJsonValue(JacksonUtil.valueToTree(securitySettings)); + AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); + try { + return JacksonUtil.convertValue(savedAdminSettings.getJsonValue(), SecuritySettings.class); + } catch (Exception e) { + throw new RuntimeException("Failed to load security settings!", e); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java new file mode 100644 index 0000000000..8b9e3bfeee --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2024 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.dao.settings; + +import org.thingsboard.server.common.data.security.model.SecuritySettings; + +public interface SecuritySettingsService { + + SecuritySettings getSecuritySettings(); + + SecuritySettings saveSecuritySettings(SecuritySettings securitySettings); + +} 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 34602d81b3..0dbc8af201 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 @@ -60,6 +60,7 @@ import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; +import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.ArrayList; @@ -69,6 +70,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.StringUtils.generateSafeToken; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -99,6 +101,7 @@ public class UserServiceImpl extends AbstractCachedEntityService userValidator; private final DataValidator userCredentialsValidator; private final ApplicationEventPublisher eventPublisher; @@ -175,9 +178,9 @@ public class UserServiceImpl extends AbstractCachedEntityService Date: Wed, 24 Jul 2024 13:23:18 +0300 Subject: [PATCH 03/34] UI for password reset and user activation links TTL (Security settings) --- .../admin/security-settings.component.html | 38 +++++++++++++++++++ .../admin/security-settings.component.ts | 3 ++ .../src/app/shared/models/settings.models.ts | 5 +++ .../assets/locale/locale.constant-en_US.json | 6 +++ 4 files changed, 52 insertions(+) diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html index 3d399d01a1..0b9df7c5ae 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html @@ -46,6 +46,44 @@ + + admin.user-activation-token-ttl + + + {{ 'admin.user-activation-token-ttl-range' | translate }} + + + {{ 'admin.user-activation-token-ttl-range' | translate }} + + + + admin.password-reset-token-ttl + + + {{ 'admin.password-reset-token-ttl-range' | translate }} + + + {{ 'admin.password-reset-token-ttl-range' | translate }} + + + + admin.mobile-secret-key-length + + + {{ 'admin.mobile-secret-key-length-range' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts index c7ac489753..712e2bb358 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts @@ -75,6 +75,9 @@ export class SecuritySettingsComponent extends PageComponent implements HasConfi this.securitySettingsFormGroup = this.fb.group({ maxFailedLoginAttempts: [null, [Validators.min(0)]], userLockoutNotificationEmail: ['', []], + userActivationTokenTtl: [24, [Validators.required, Validators.min(1), Validators.max(24)]], + passwordResetTokenTtl: [24, [Validators.required, Validators.min(1), Validators.max(24)]], + mobileSecretKeyLength: [null, [Validators.min(1)]], passwordPolicy: this.fb.group( { minimumLength: [null, [Validators.required, Validators.min(6), Validators.max(50)]], diff --git a/ui-ngx/src/app/shared/models/settings.models.ts b/ui-ngx/src/app/shared/models/settings.models.ts index 113bc1a789..e0a31e4eea 100644 --- a/ui-ngx/src/app/shared/models/settings.models.ts +++ b/ui-ngx/src/app/shared/models/settings.models.ts @@ -111,6 +111,11 @@ export interface UserPasswordPolicy { export interface SecuritySettings { passwordPolicy: UserPasswordPolicy; + maxFailedLoginAttempts: number; + userLockoutNotificationEmail: string; + mobileSecretKeyLength: number; + userActivationTokenTtl: number; + passwordResetTokenTtl: number; } export interface JwtSettings { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 33c8d25652..3e56a104fb 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -205,6 +205,12 @@ "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", + "user-activation-token-ttl": "User activation link TTL in hours", + "user-activation-token-ttl-range": "User activation link TTL must be in range from 1 to 24 hours", + "password-reset-token-ttl": "Password reset link TTL in hours", + "password-reset-token-ttl-range": "Password reset link TTL must be in range from 1 to 24 hours", + "mobile-secret-key-length": "Mobile secret key length", + "mobile-secret-key-length-range": "Mobile secret key length must be positive", "domain-name": "Domain name", "domain-name-unique": "Domain name and protocol need to unique.", "domain-name-max-length": "Domain name should be less than 256", From a246032e543c99983179990f409aef8e4a0955b2 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 24 Jul 2024 13:41:25 +0300 Subject: [PATCH 04/34] Clear security settings cache on upgrade --- .../org/thingsboard/server/controller/AuthController.java | 6 +----- .../service/install/update/DefaultCacheCleanupService.java | 5 +++++ 2 files changed, 6 insertions(+), 5 deletions(-) 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 86301cfb6a..5d10648be7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -31,8 +31,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; -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.cache.limits.RateLimitService; @@ -86,8 +84,7 @@ public class AuthController extends BaseController { notes = "Get the information about the User which credentials are used to perform this REST API call.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/auth/user") - public @ResponseBody - User getUser() throws ThingsboardException { + public User getUser() throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); return userService.findUserById(securityUser.getTenantId(), securityUser.getId()); } @@ -96,7 +93,6 @@ public class AuthController extends BaseController { notes = "Special API call to record the 'logout' of the user to the Audit Logs. Since platform uses [JWT](https://jwt.io/), the actual logout is the procedure of clearing the [JWT](https://jwt.io/) token on the client side. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/auth/logout") - @ResponseStatus(value = HttpStatus.OK) public void logout(HttpServletRequest request) throws ThingsboardException { logLogoutAction(request); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index 7fd92bc5c6..f5b206d367 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -61,6 +61,10 @@ public class DefaultCacheCleanupService implements CacheCleanupService { log.info("Clearing cache to upgrade from version 3.6.4 to 3.7.0"); clearAll(); break; + case "3.7.0": + log.info("Clearing cache to upgrade from version 3.7.0 to 3.7.1"); + clearCacheByName(SECURITY_SETTINGS_CACHE); + break; default: //Do nothing, since cache cleanup is optional. } @@ -88,4 +92,5 @@ public class DefaultCacheCleanupService implements CacheCleanupService { } cacheManager.getCacheNames().forEach(this::clearCacheByName); } + } From 478d20aec73cdebdf8e9fcc5de05fdd8894635ed Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Thu, 25 Jul 2024 11:49:10 +0300 Subject: [PATCH 05/34] Status '410 Gone' when token is expired --- .../server/controller/AuthController.java | 74 +++++++++---------- .../server/controller/BaseController.java | 6 ++ .../server/controller/ImageController.java | 2 +- .../MobileApplicationController.java | 3 +- .../server/controller/AuthControllerTest.java | 4 +- 5 files changed, 46 insertions(+), 43 deletions(-) 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 5d10648be7..aa194d688e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -21,7 +21,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -132,28 +131,28 @@ public class AuthController extends BaseController { @ApiOperation(value = "Check Activate User Token (checkActivateToken)", notes = "Checks the activation token and forwards user to 'Create Password' page. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + - "If token is not valid, returns '409 Conflict'.") + "If token is not valid, returns '409 Conflict'. " + + "If token is expired, returns '410 Gone'.") @GetMapping(value = "/noauth/activate", params = {"activateToken"}) - public ResponseEntity checkActivateToken( + public ResponseEntity checkActivateToken( @Parameter(description = "The activate token string.") @RequestParam(value = "activateToken") String activateToken) { - HttpHeaders headers = new HttpHeaders(); - HttpStatus responseStatus; UserCredentials userCredentials = userService.findUserCredentialsByActivateToken(TenantId.SYS_TENANT_ID, activateToken); - if (userCredentials != null && !userCredentials.isActivationTokenExpired()) { - String createURI = "/login/createPassword"; - try { - URI location = new URI(createURI + "?activateToken=" + activateToken); - headers.setLocation(location); - responseStatus = HttpStatus.SEE_OTHER; - } catch (URISyntaxException e) { - log.error("Unable to create URI with address [{}]", createURI); - responseStatus = HttpStatus.BAD_REQUEST; - } - } else { - responseStatus = HttpStatus.CONFLICT; + if (userCredentials == null) { + return response(HttpStatus.CONFLICT); + } else if (userCredentials.isActivationTokenExpired()) { + return response(HttpStatus.GONE); + } + + String createURI = "/login/createPassword"; + try { + URI location = new URI(createURI + "?activateToken=" + activateToken); + return ResponseEntity.status(HttpStatus.SEE_OTHER) + .location(location).build(); + } catch (URISyntaxException e) { + log.error("Unable to create URI with address [{}]", createURI); + return response(HttpStatus.BAD_REQUEST); } - return new ResponseEntity<>(headers, responseStatus); } @ApiOperation(value = "Request reset password email (requestResetPasswordByEmail)", @@ -181,32 +180,31 @@ public class AuthController extends BaseController { @ApiOperation(value = "Check password reset token (checkResetToken)", notes = "Checks the password reset token and forwards user to 'Reset Password' page. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + - "If token is not valid, returns '409 Conflict'.") + "If token is not valid, returns '409 Conflict'. " + + "If token is expired, returns '410 Gone'.") @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"}) - public ResponseEntity checkResetToken( + public ResponseEntity checkResetToken( @Parameter(description = "The reset token string.") @RequestParam(value = "resetToken") String resetToken) { - HttpHeaders headers = new HttpHeaders(); - HttpStatus responseStatus; - String resetURI = "/login/resetPassword"; UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); + if (userCredentials == null) { + return response(HttpStatus.CONFLICT); + } else if (userCredentials.isResetTokenExpired()) { + return response(HttpStatus.GONE); + } + if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { + return response(HttpStatus.TOO_MANY_REQUESTS); + } - if (userCredentials != null && !userCredentials.isResetTokenExpired()) { - if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); - } - try { - URI location = new URI(resetURI + "?resetToken=" + resetToken); - headers.setLocation(location); - responseStatus = HttpStatus.SEE_OTHER; - } catch (URISyntaxException e) { - log.error("Unable to create URI with address [{}]", resetURI); - responseStatus = HttpStatus.BAD_REQUEST; - } - } else { - responseStatus = HttpStatus.CONFLICT; + String resetURI = "/login/resetPassword"; + try { + URI location = new URI(resetURI + "?resetToken=" + resetToken); + return ResponseEntity.status(HttpStatus.SEE_OTHER) + .location(location).build(); + } catch (URISyntaxException e) { + log.error("Unable to create URI with address [{}]", resetURI); + return response(HttpStatus.BAD_REQUEST); } - return new ResponseEntity<>(headers, responseStatus); } @ApiOperation(value = "Activate User", diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index da956d4fa5..52772bbf2b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -27,7 +27,9 @@ import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -867,4 +869,8 @@ public abstract class BaseController { } } + protected ResponseEntity response(HttpStatus status) { + return ResponseEntity.status(status).build(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index e8e7ca1d8f..0228739399 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -290,7 +290,7 @@ public class ImageController extends BaseController { if (StringUtils.isNotEmpty(etag)) { etag = StringUtils.remove(etag, '\"'); // etag is wrapped in double quotes due to HTTP specification if (etag.equals(tbImageService.getETag(cacheKey))) { - return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); + return response(HttpStatus.NOT_MODIFIED); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index ca8b11ee80..11f017b85f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -183,8 +183,7 @@ public class MobileApplicationController extends BaseController { .header("Location", appStoreLink) .build(); } else { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .build(); + return response(HttpStatus.NOT_FOUND); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index f3c0beab8f..dba5a84e1b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -193,7 +193,7 @@ public class AuthControllerTest extends AbstractControllerTest { userCredentialsDao.save(tenantId, userCredentials); doGet("/api/noauth/resetPassword?resetToken={resetToken}", this.currentResetPasswordToken) - .andExpect(status().isConflict()); + .andExpect(status().isGone()); JsonNode resetPasswordRequest = JacksonUtil.newObjectNode() .put("resetToken", this.currentResetPasswordToken) .put("password", "wefwefe"); @@ -223,7 +223,7 @@ public class AuthControllerTest extends AbstractControllerTest { userCredentials.setActivateTokenExpTime(System.currentTimeMillis() - 1); userCredentialsDao.save(tenantId, userCredentials); doGet("/api/noauth/activate?activateToken={activateToken}", initialActivationToken) - .andExpect(status().isConflict()); + .andExpect(status().isGone()); doPost("/api/noauth/activate", JacksonUtil.newObjectNode() .put("activateToken", initialActivationToken) .put("password", "wefewe")).andExpect(status().isBadRequest()) From 864ba221eacfb300382e12fcc9be7e16a1cab6de Mon Sep 17 00:00:00 2001 From: d2eight Date: Mon, 29 Jul 2024 19:38:13 +0300 Subject: [PATCH 06/34] Card-padding for some widgets --- .../value-card-basic-config.component.html | 6 ++ .../value-card-basic-config.component.ts | 2 + .../status-widget-basic-config.component.html | 6 ++ .../status-widget-basic-config.component.ts | 5 +- .../single-switch-basic-config.component.html | 6 ++ .../single-switch-basic-config.component.ts | 2 + ...peed-direction-basic-config.component.html | 6 ++ ...-speed-direction-basic-config.component.ts | 2 + .../cards/value-card-widget.component.html | 56 +++++++++---------- .../lib/cards/value-card-widget.component.ts | 17 +++++- .../lib/cards/value-card-widget.models.ts | 4 +- .../indicator/status-widget.component.html | 2 +- .../indicator/status-widget.component.scss | 1 - .../lib/indicator/status-widget.component.ts | 16 +++++- .../lib/indicator/status-widget.models.ts | 4 +- .../rpc/single-switch-widget.component.html | 2 +- .../lib/rpc/single-switch-widget.component.ts | 16 +++++- .../lib/rpc/single-switch-widget.models.ts | 4 +- .../value-card-widget-settings.component.html | 6 ++ .../value-card-widget-settings.component.ts | 3 +- ...ngle-switch-widget-settings.component.html | 6 ++ ...single-switch-widget-settings.component.ts | 3 +- .../status-widget-settings.component.html | 6 ++ .../status-widget-settings.component.ts | 3 +- ...d-direction-widget-settings.component.html | 6 ++ ...eed-direction-widget-settings.component.ts | 3 +- ...wind-speed-direction-widget.component.html | 2 +- .../wind-speed-direction-widget.component.ts | 2 + .../wind-speed-direction-widget.models.ts | 4 +- 29 files changed, 151 insertions(+), 50 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html index 44e288d2e0..c1f0d7e635 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html @@ -126,6 +126,12 @@ +
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts index 0bffd39ce4..5f14b7569e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts @@ -144,6 +144,7 @@ export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent { cardButtons: [this.getCardButtons(configData.config), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -183,6 +184,7 @@ export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent { this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html index 57eb184009..2370d5bdbb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html @@ -94,6 +94,12 @@ +
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts index d2110026f6..c54df3321c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts @@ -29,7 +29,7 @@ import { statusWidgetLayoutImages, statusWidgetLayouts, statusWidgetLayoutTranslations, - StatusWidgetSettings + StatusWidgetSettings, StatusWidgetStateSettings } from '@home/components/widget/lib/indicator/status-widget.models'; @Component({ @@ -66,6 +66,7 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent protected onConfigSet(configData: WidgetConfigComponentData) { const settings: StatusWidgetSettings = {...statusWidgetDefaultSettings, ...(configData.config.settings || {})}; + this.statusWidgetConfigForm = this.fb.group({ targetDevice: [configData.config.targetDevice, []], @@ -79,6 +80,7 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent cardButtons: [this.getCardButtons(configData.config), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -99,6 +101,7 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html index c6ac3abb45..1009057b19 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html @@ -211,6 +211,12 @@ +
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
widget-config.show-card-buttons
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts index 75c93bc852..dfdc9e8eb0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts @@ -108,6 +108,7 @@ export class SingleSwitchBasicConfigComponent extends BasicWidgetConfigComponent cardButtons: [this.getCardButtons(configData.config), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -159,6 +160,7 @@ export class SingleSwitchBasicConfigComponent extends BasicWidgetConfigComponent this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html index 7854a9f3b3..0dd9a2ebd9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html @@ -208,6 +208,12 @@
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts index fca3bd2502..08e7003b65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts @@ -147,6 +147,7 @@ export class WindSpeedDirectionBasicConfigComponent extends BasicWidgetConfigCom cardButtons: [this.getCardButtons(configData.config), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -198,6 +199,7 @@ export class WindSpeedDirectionBasicConfigComponent extends BasicWidgetConfigCom this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html index 72598a4cdf..3d8ebd76b8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html @@ -15,41 +15,41 @@ limitations under the License. --> -
+
- - - - - - - - - - - - -
+ + + + + + + + + + + +
+ + +
+ +
+ -
- -
- - - - - - -
- -
-
+ + + + +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts index ed361c1e76..440c2d4056 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts @@ -37,7 +37,7 @@ import { getLabel, getSingleTsValue, iconStyle, - overlayStyle, + overlayStyle, resolveCssSize, textStyle } from '@shared/models/widget-settings.models'; import { valueCardDefaultSettings, ValueCardLayout, ValueCardWidgetSettings } from './value-card-widget.models'; @@ -96,6 +96,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; + padding: string; private panelResize$: ResizeObserver; @@ -139,6 +140,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro this.labelStyle = textStyle(this.settings.labelFont); this.labelColor = ColorProcessor.fromSettings(this.settings.labelColor); this.valueStyle = textStyle(this.settings.valueFont); + console.log(this.valueStyle); this.valueColor = ColorProcessor.fromSettings(this.settings.valueColor); this.showDate = this.settings.showDate; @@ -148,6 +150,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; } public ngAfterViewInit() { @@ -199,8 +202,16 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro } private onResize() { - const panelWidth = this.valueCardPanel.nativeElement.getBoundingClientRect().width - squareLayoutPadding; - const panelHeight = this.valueCardPanel.nativeElement.getBoundingClientRect().height - (this.horizontal ? 0 : squareLayoutPadding); + const paddingLeft = getComputedStyle(this.valueCardPanel.nativeElement).paddingLeft; + const paddingRight = getComputedStyle(this.valueCardPanel.nativeElement).paddingRight; + const paddingTop = getComputedStyle(this.valueCardPanel.nativeElement).paddingTop; + const paddingBottom = getComputedStyle(this.valueCardPanel.nativeElement).paddingBottom; + const pLeft = resolveCssSize(paddingLeft)[0]; + const pRight = resolveCssSize(paddingRight)[0]; + const pTop = resolveCssSize(paddingTop)[0]; + const pBottom = resolveCssSize(paddingBottom)[0]; + const panelWidth = this.valueCardPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight); + const panelHeight = this.valueCardPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom); let scale: number; if (!this.horizontal) { const size = Math.min(panelWidth, panelHeight); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts index df5fc78e9a..7b46ccba4d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts @@ -80,6 +80,7 @@ export interface ValueCardWidgetSettings { dateFont: Font; dateColor: ColorSettings; background: BackgroundSettings; + padding: string; } export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSettings => ({ @@ -128,5 +129,6 @@ export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSe color: 'rgba(255,255,255,0.72)', blur: 3 } - } + }, + padding: '12px' }); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html index 391ade6228..4a4ff409fd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss index 8dcefddd2a..a2e32fcfcc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss @@ -38,7 +38,6 @@ .tb-status-widget-content { width: 100%; height: 100%; - padding: 16px; position: relative; display: flex; flex-direction: column; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts index 5b59b0210f..3f85917897 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts @@ -35,7 +35,7 @@ import { backgroundStyle, ComponentStyle, iconStyle, - overlayStyle, + overlayStyle, resolveCssSize, textStyle } from '@shared/models/widget-settings.models'; import { ResizeObserver } from '@juggle/resize-observer'; @@ -65,6 +65,7 @@ export class StatusWidgetComponent extends backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; + padding: string; overlayInset = '12px'; borderRadius = ''; @@ -191,8 +192,16 @@ export class StatusWidgetComponent extends } private onResize() { - const panelWidth = this.statusWidgetPanel.nativeElement.getBoundingClientRect().width; - const panelHeight = this.statusWidgetPanel.nativeElement.getBoundingClientRect().height; + const paddingLeft = getComputedStyle(this.statusWidgetPanel.nativeElement).paddingLeft; + const paddingRight = getComputedStyle(this.statusWidgetPanel.nativeElement).paddingRight; + const paddingTop = getComputedStyle(this.statusWidgetPanel.nativeElement).paddingTop; + const paddingBottom = getComputedStyle(this.statusWidgetPanel.nativeElement).paddingBottom; + const pLeft = resolveCssSize(paddingLeft)[0]; + const pRight = resolveCssSize(paddingRight)[0]; + const pTop = resolveCssSize(paddingTop)[0]; + const pBottom = resolveCssSize(paddingBottom)[0]; + const panelWidth = this.statusWidgetPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight); + const panelHeight = this.statusWidgetPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom); const targetSize = Math.min(panelWidth, panelHeight); const scale = targetSize / initialStatusWidgetSize; const width = initialStatusWidgetSize; @@ -220,6 +229,7 @@ export class StatusWidgetComponent extends this.showLabel = stateSettings.showLabel && this.layout !== StatusWidgetLayout.icon; this.showStatus = stateSettings.showStatus && this.layout !== StatusWidgetLayout.icon; this.icon = stateSettings.icon; + this.padding = stateSettings.background.overlay.enabled ? undefined : this.settings.padding; const primaryColor = disabled ? stateSettings.primaryColorDisabled : stateSettings.primaryColor; const secondaryColor = disabled ? stateSettings.secondaryColorDisabled : stateSettings.secondaryColor; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts index 2ef999c6f2..8da94a3739 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts @@ -65,6 +65,7 @@ export interface StatusWidgetSettings { layout: StatusWidgetLayout; onState: StatusWidgetStateSettings; offState: StatusWidgetStateSettings; + padding: string } export const statusWidgetDefaultSettings: StatusWidgetSettings = { @@ -200,5 +201,6 @@ export const statusWidgetDefaultSettings: StatusWidgetSettings = { blur: 3 } } - } + }, + padding: '12px' }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html index fd9aaca4a6..a8dde3ed4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts index 2c15634893..5bfede43f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts @@ -35,7 +35,7 @@ import { backgroundStyle, ComponentStyle, iconStyle, - overlayStyle, + overlayStyle, resolveCssSize, textStyle } from '@shared/models/widget-settings.models'; import { Observable } from 'rxjs'; @@ -74,6 +74,7 @@ export class SingleSwitchWidgetComponent extends backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; + padding: string; overlayInset = '12px'; value = false; @@ -123,6 +124,7 @@ export class SingleSwitchWidgetComponent extends this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; this.layout = this.settings.layout; @@ -234,8 +236,16 @@ export class SingleSwitchWidgetComponent extends const height = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height; const switchScale = height / initialSwitchHeight; const paddingScale = Math.min(switchScale, 1); - const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - (horizontalLayoutPadding * paddingScale); - const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - (verticalLayoutPadding * paddingScale); + const paddingLeft = getComputedStyle(this.singleSwitchPanel.nativeElement).paddingLeft; + const paddingRight = getComputedStyle(this.singleSwitchPanel.nativeElement).paddingRight; + const paddingTop = getComputedStyle(this.singleSwitchPanel.nativeElement).paddingTop; + const paddingBottom = getComputedStyle(this.singleSwitchPanel.nativeElement).paddingBottom; + const pLeft = resolveCssSize(paddingLeft)[0]; + const pRight = resolveCssSize(paddingRight)[0]; + const pTop = resolveCssSize(paddingTop)[0]; + const pBottom = resolveCssSize(paddingBottom)[0]; + const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight); + const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom); this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(1)`); this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', 'auto'); let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts index 94586c31de..2fe9ab0d96 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts @@ -80,6 +80,7 @@ export interface SingleSwitchWidgetSettings { offLabelFont: Font; offLabelColor: string; background: BackgroundSettings; + padding: string; } export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { @@ -217,5 +218,6 @@ export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { color: 'rgba(255,255,255,0.72)', blur: 3 } - } + }, + padding: '12px' }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html index 76f46862a6..5077c93d8e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html @@ -93,5 +93,11 @@
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts index 9693ca22d4..be7794802c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts @@ -110,7 +110,8 @@ export class ValueCardWidgetSettingsComponent extends WidgetSettingsComponent { dateFont: [settings.dateFont, []], dateColor: [settings.dateColor, []], - background: [settings.background, []] + background: [settings.background, []], + padding: [settings.padding, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html index 9208efd68a..e08b40882b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html @@ -123,6 +123,12 @@
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
widgets.single-switch.switch
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts index a1a3e951f2..a4a76e5294 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts @@ -102,7 +102,8 @@ export class SingleSwitchWidgetSettingsComponent extends WidgetSettingsComponent offLabelFont: [settings.offLabelFont, []], offLabelColor: [settings.offLabelColor, []], - background: [settings.background, []] + background: [settings.background, []], + padding: [settings.padding, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html index 180340881d..c6f65cb5e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html @@ -78,5 +78,11 @@ [layout]="statusWidgetSettingsForm.get('layout').value" formControlName="offState"> +
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts index 03da985a0e..1130b58b21 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts @@ -73,7 +73,8 @@ export class StatusWidgetSettingsComponent extends WidgetSettingsComponent { disabledState: [settings.disabledState, []], layout: [settings.layout, []], onState: [settings.onState, []], - offState: [settings.offState, []] + offState: [settings.offState, []], + padding: [settings.padding, []] }); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html index 9265164c52..5271825ccf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html @@ -100,5 +100,11 @@
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts index e6b8880758..2d16a030b7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts @@ -91,7 +91,8 @@ export class WindSpeedDirectionWidgetSettingsComponent extends WidgetSettingsCom arrowColor: [settings.arrowColor, []], - background: [settings.background, []] + background: [settings.background, []], + padding: [settings.padding, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html index 80ca1960f9..0ec08c2a05 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts index 4ce5cf21d7..0381e8531f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts @@ -92,6 +92,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; + padding: string; shapeResize$: ResizeObserver; @@ -142,6 +143,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts index 7801f80a17..5c1a5518e8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts @@ -59,6 +59,7 @@ export interface WindSpeedDirectionWidgetSettings { minorTicksColor: string; minorTicksFont: Font; background: BackgroundSettings; + padding: string } export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings = { @@ -101,5 +102,6 @@ export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings color: 'rgba(255,255,255,0.72)', blur: 3 } - } + }, + padding: '12px' }; From 08620e80e4c5c9e207ba6fc036e8b019a200662e Mon Sep 17 00:00:00 2001 From: d2eight Date: Tue, 30 Jul 2024 11:50:53 +0300 Subject: [PATCH 07/34] Some fixes --- .../basic/indicator/status-widget-basic-config.component.ts | 3 +-- .../widget/lib/cards/value-card-widget.component.html | 4 ++-- .../components/widget/lib/indicator/status-widget.models.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts index c54df3321c..0e012cefe7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts @@ -29,7 +29,7 @@ import { statusWidgetLayoutImages, statusWidgetLayouts, statusWidgetLayoutTranslations, - StatusWidgetSettings, StatusWidgetStateSettings + StatusWidgetSettings } from '@home/components/widget/lib/indicator/status-widget.models'; @Component({ @@ -66,7 +66,6 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent protected onConfigSet(configData: WidgetConfigComponentData) { const settings: StatusWidgetSettings = {...statusWidgetDefaultSettings, ...(configData.config.settings || {})}; - this.statusWidgetConfigForm = this.fb.group({ targetDevice: [configData.config.targetDevice, []], diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html index 3d8ebd76b8..814f9f08d8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html @@ -21,8 +21,8 @@
- - + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts index 8da94a3739..d9daebba31 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts @@ -202,5 +202,5 @@ export const statusWidgetDefaultSettings: StatusWidgetSettings = { } } }, - padding: '12px' + padding: '16px' }; From db321d92da64e6b978b525e4bf1acea0abd49ec5 Mon Sep 17 00:00:00 2001 From: d2eight Date: Tue, 30 Jul 2024 12:01:02 +0300 Subject: [PATCH 08/34] delete console.log --- .../components/widget/lib/cards/value-card-widget.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts index 440c2d4056..d28f62ee57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts @@ -140,7 +140,6 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro this.labelStyle = textStyle(this.settings.labelFont); this.labelColor = ColorProcessor.fromSettings(this.settings.labelColor); this.valueStyle = textStyle(this.settings.valueFont); - console.log(this.valueStyle); this.valueColor = ColorProcessor.fromSettings(this.settings.valueColor); this.showDate = this.settings.showDate; From 1ebb241520d38eb0834352a391d2bcfc6694c601 Mon Sep 17 00:00:00 2001 From: d2eight Date: Tue, 30 Jul 2024 12:49:51 +0300 Subject: [PATCH 09/34] status widget fix --- .../widget/lib/indicator/status-widget.component.scss | 2 +- .../components/widget/lib/indicator/status-widget.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss index a2e32fcfcc..9585cdb0eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.scss @@ -20,7 +20,7 @@ display: flex; align-items: center; justify-content: center; - padding: 0; + padding: 16px; > div:not(.tb-status-widget-overlay), > tb-icon { z-index: 1; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts index 3f85917897..77de5bc39c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts @@ -229,7 +229,7 @@ export class StatusWidgetComponent extends this.showLabel = stateSettings.showLabel && this.layout !== StatusWidgetLayout.icon; this.showStatus = stateSettings.showStatus && this.layout !== StatusWidgetLayout.icon; this.icon = stateSettings.icon; - this.padding = stateSettings.background.overlay.enabled ? undefined : this.settings.padding; + this.padding = stateSettings.backgroundDisabled.overlay.enabled || stateSettings.background.overlay.enabled ? undefined : this.settings.padding; const primaryColor = disabled ? stateSettings.primaryColorDisabled : stateSettings.primaryColor; const secondaryColor = disabled ? stateSettings.secondaryColorDisabled : stateSettings.secondaryColor; From e41b62737133f314259616f68d2084f228c70d3a Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 7 Aug 2024 14:24:53 +0300 Subject: [PATCH 10/34] Renew activation link if less than 15 minutes before expiration --- .../server/controller/UserController.java | 3 ++- .../server/controller/AuthControllerTest.java | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) 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 c76958dabe..234a2096d1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -85,6 +85,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; import static org.thingsboard.server.controller.ControllerConstants.ALARM_ID_PARAM_DESCRIPTION; @@ -590,7 +591,7 @@ public class UserController extends BaseController { TenantId tenantId = getTenantId(); UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { - if (userCredentials.isActivationTokenExpired()) { + if (System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15) > userCredentials.getActivateTokenExpTime()) { // renew link if less than 15 minutes before expiration userCredentials = userService.generateUserActivationToken(userCredentials); userCredentials = userService.saveUserCredentials(tenantId, userCredentials); log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userId); diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index dba5a84e1b..de60cb61ea 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -220,6 +220,7 @@ public class AuthControllerTest extends AbstractControllerTest { String initialActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); String initialActivationToken = StringUtils.substringAfterLast(initialActivationLink, "activateToken="); + // expiring activation token userCredentials.setActivateTokenExpTime(System.currentTimeMillis() - 1); userCredentialsDao.save(tenantId, userCredentials); doGet("/api/noauth/activate?activateToken={activateToken}", initialActivationToken) @@ -229,14 +230,26 @@ public class AuthControllerTest extends AbstractControllerTest { .put("password", "wefewe")).andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message", is("Activation token expired"))); + // checking that activation link is regenerated when requested String regeneratedActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); - String regeneratedActivationToken = StringUtils.substringAfterLast(regeneratedActivationLink, "activateToken="); - assertThat(regeneratedActivationToken).isNotEqualTo(initialActivationLink); + assertThat(regeneratedActivationLink).isNotEqualTo(initialActivationLink); + + // checking link renewal if less than 15 minutes before expiration + userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); + userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)); + userCredentialsDao.save(tenantId, userCredentials); + assertThat(doGet("/api/user/" + user.getId() + "/activationLink", String.class)).isEqualTo(regeneratedActivationLink); + userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10)); + userCredentialsDao.save(tenantId, userCredentials); + String newActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); + assertThat(newActivationLink).isNotEqualTo(regeneratedActivationLink); + String newActivationToken = StringUtils.substringAfterLast(newActivationLink, "activateToken="); + userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); doPost("/api/noauth/activate", JacksonUtil.newObjectNode() - .put("activateToken", regeneratedActivationToken) + .put("activateToken", newActivationToken) .put("password", "wefewe")).andExpect(status().isOk()); } From 9c68204845094000ebce0e4358578a7658d6baad Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 14 Aug 2024 13:17:35 +0300 Subject: [PATCH 11/34] Add activation link info API --- .../server/controller/UserController.java | 30 +++++++++++++---- .../server/controller/AuthControllerTest.java | 32 +++++++++++++++---- .../common/data/security/UserCredentials.java | 7 +++- 3 files changed, 54 insertions(+), 15 deletions(-) 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 234a2096d1..823129d59b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -229,19 +229,30 @@ public class UserController extends BaseController { User user = checkNotNull(userService.findUserByEmail(getCurrentUser().getTenantId(), email)); accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, user.getId(), user); - String activationLink = getActivationLink(user.getId(), request); - mailService.sendActivationEmail(activationLink, email); + ActivationLink activationLink = getActivationLink(user.getId(), request); + mailService.sendActivationEmail(activationLink.value(), email); } - @ApiOperation(value = "Get the activation link (getActivationLink)", + @ApiOperation(value = "Get activation link (getActivationLink)", notes = "Get the activation link for the user. " + "The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/user/{userId}/activationLink", method = RequestMethod.GET, produces = "text/plain") + @GetMapping(value = "/user/{userId}/activationLink", produces = "text/plain") @ResponseBody public String getActivationLink(@Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId, HttpServletRequest request) throws ThingsboardException { + return getActivationLinkInfo(strUserId, request).value(); + } + + @ApiOperation(value = "Get activation link info (getActivationLinkInfo)", + notes = "Get the activation link info for the user. " + + "The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/user/{userId}/activationLinkInfo") + public ActivationLink getActivationLinkInfo(@Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + HttpServletRequest request) throws ThingsboardException { checkParameter(USER_ID, strUserId); UserId userId = new UserId(toUUID(strUserId)); checkUserId(userId, Operation.READ); @@ -587,17 +598,20 @@ public class UserController extends BaseController { userService.removeMobileSession(user.getTenantId(), mobileToken); } - private String getActivationLink(UserId userId, HttpServletRequest request) throws ThingsboardException { + private ActivationLink getActivationLink(UserId userId, HttpServletRequest request) throws ThingsboardException { TenantId tenantId = getTenantId(); UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { - if (System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15) > userCredentials.getActivateTokenExpTime()) { // renew link if less than 15 minutes before expiration + long ttl = userCredentials.getActivationTokenTtl(); + if (ttl < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration userCredentials = userService.generateUserActivationToken(userCredentials); userCredentials = userService.saveUserCredentials(tenantId, userCredentials); + ttl = userCredentials.getActivationTokenTtl(); log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userId); } String baseUrl = systemSecurityService.getBaseUrl(tenantId, getCurrentUser().getCustomerId(), request); - return String.format(ACTIVATE_URL_PATTERN, baseUrl, userCredentials.getActivateToken()); + String link = String.format(ACTIVATE_URL_PATTERN, baseUrl, userCredentials.getActivateToken()); + return new ActivationLink(link, ttl); } else { throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); } @@ -609,4 +623,6 @@ public class UserController extends BaseController { } } + record ActivationLink(String value, long ttlMs) {} + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index de60cb61ea..e4010713d8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.controller.UserController.ActivationLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.user.UserCredentialsDao; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -38,6 +39,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -217,8 +219,11 @@ public class AuthControllerTest extends AbstractControllerTest { UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); - String initialActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); + String initialActivationLink = getActivationLink(user); String initialActivationToken = StringUtils.substringAfterLast(initialActivationLink, "activateToken="); + ActivationLink activationLinkInfo = getActivationLinkInfo(user); + assertThat(TimeUnit.MILLISECONDS.toHours(activationLinkInfo.ttlMs())).isCloseTo(ttl, within(1L)); + assertThat(activationLinkInfo.value()).isEqualTo(initialActivationLink); // expiring activation token userCredentials.setActivateTokenExpTime(System.currentTimeMillis() - 1); @@ -231,19 +236,24 @@ public class AuthControllerTest extends AbstractControllerTest { .andExpect(jsonPath("$.message", is("Activation token expired"))); // checking that activation link is regenerated when requested - String regeneratedActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); - assertThat(regeneratedActivationLink).isNotEqualTo(initialActivationLink); + ActivationLink regeneratedActivationLink = getActivationLinkInfo(user); + assertThat(regeneratedActivationLink.value()).isNotEqualTo(initialActivationLink); + assertThat(TimeUnit.MILLISECONDS.toHours(regeneratedActivationLink.ttlMs())).isCloseTo(ttl, within(1L)); // checking link renewal if less than 15 minutes before expiration userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)); userCredentialsDao.save(tenantId, userCredentials); - assertThat(doGet("/api/user/" + user.getId() + "/activationLink", String.class)).isEqualTo(regeneratedActivationLink); + activationLinkInfo = getActivationLinkInfo(user); + assertThat(activationLinkInfo.value()).isEqualTo(regeneratedActivationLink.value()); + assertThat(TimeUnit.MILLISECONDS.toMinutes(activationLinkInfo.ttlMs())).isCloseTo(30, within(1L)); + userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10)); userCredentialsDao.save(tenantId, userCredentials); - String newActivationLink = doGet("/api/user/" + user.getId() + "/activationLink", String.class); - assertThat(newActivationLink).isNotEqualTo(regeneratedActivationLink); - String newActivationToken = StringUtils.substringAfterLast(newActivationLink, "activateToken="); + ActivationLink newActivationLink = getActivationLinkInfo(user); + assertThat(newActivationLink.value()).isNotEqualTo(regeneratedActivationLink.value()); + assertThat(TimeUnit.MILLISECONDS.toHours(newActivationLink.ttlMs())).isCloseTo(ttl, within(1L)); + String newActivationToken = StringUtils.substringAfterLast(newActivationLink.value(), "activateToken="); userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId()); assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); @@ -265,4 +275,12 @@ public class AuthControllerTest extends AbstractControllerTest { doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); } + private String getActivationLink(User user) throws Exception { + return doGet("/api/user/" + user.getId() + "/activationLink", String.class); + } + + private ActivationLink getActivationLinkInfo(User user) throws Exception { + return doGet("/api/user/" + user.getId() + "/activationLinkInfo", ActivationLink.class); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java index 06645feec3..f6e23b9e1a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java @@ -52,7 +52,12 @@ public class UserCredentials extends BaseDataWithAdditionalInfo activateTokenExpTime; + return getActivationTokenTtl() == 0; + } + + @JsonIgnore + public long getActivationTokenTtl() { + return activateTokenExpTime != null ? Math.max(activateTokenExpTime - System.currentTimeMillis(), 0) : 0; } @JsonIgnore From a8afd8929bf5c9537b8aaa70c7fd9408da65220a Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 4 Sep 2024 17:29:38 +0300 Subject: [PATCH 12/34] Redirect to error page when password reset or activation link is expired --- .../server/controller/AuthController.java | 33 ++++--------------- .../server/controller/BaseController.java | 17 ++++++++-- .../server/controller/AuthControllerTest.java | 6 ++-- 3 files changed, 25 insertions(+), 31 deletions(-) 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 aa194d688e..a52a80f9f0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -58,9 +58,6 @@ import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.system.SystemSecurityService; -import java.net.URI; -import java.net.URISyntaxException; - @RestController @TbCoreComponent @RequestMapping("/api") @@ -132,7 +129,7 @@ public class AuthController extends BaseController { notes = "Checks the activation token and forwards user to 'Create Password' page. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'. " + - "If token is expired, returns '410 Gone'.") + "If token is expired, redirects to error page.") @GetMapping(value = "/noauth/activate", params = {"activateToken"}) public ResponseEntity checkActivateToken( @Parameter(description = "The activate token string.") @@ -141,18 +138,9 @@ public class AuthController extends BaseController { if (userCredentials == null) { return response(HttpStatus.CONFLICT); } else if (userCredentials.isActivationTokenExpired()) { - return response(HttpStatus.GONE); - } - - String createURI = "/login/createPassword"; - try { - URI location = new URI(createURI + "?activateToken=" + activateToken); - return ResponseEntity.status(HttpStatus.SEE_OTHER) - .location(location).build(); - } catch (URISyntaxException e) { - log.error("Unable to create URI with address [{}]", createURI); - return response(HttpStatus.BAD_REQUEST); + return redirectTo("/activationLinkExpired"); } + return redirectTo("/login/createPassword?activateToken=" + activateToken); } @ApiOperation(value = "Request reset password email (requestResetPasswordByEmail)", @@ -181,7 +169,7 @@ public class AuthController extends BaseController { notes = "Checks the password reset token and forwards user to 'Reset Password' page. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'. " + - "If token is expired, returns '410 Gone'.") + "If token is expired, redirects to error page.") @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"}) public ResponseEntity checkResetToken( @Parameter(description = "The reset token string.") @@ -190,21 +178,12 @@ public class AuthController extends BaseController { if (userCredentials == null) { return response(HttpStatus.CONFLICT); } else if (userCredentials.isResetTokenExpired()) { - return response(HttpStatus.GONE); + return redirectTo("/passwordResetLinkExpired"); } if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { return response(HttpStatus.TOO_MANY_REQUESTS); } - - String resetURI = "/login/resetPassword"; - try { - URI location = new URI(resetURI + "?resetToken=" + resetToken); - return ResponseEntity.status(HttpStatus.SEE_OTHER) - .location(location).build(); - } catch (URISyntaxException e) { - log.error("Unable to create URI with address [{}]", resetURI); - return response(HttpStatus.BAD_REQUEST); - } + return redirectTo("/login/resetPassword?resetToken=" + resetToken); } @ApiOperation(value = "Activate User", diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 54d91b6cfb..d266c7e6cc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -134,8 +134,8 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.model.ModelConstants; -import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; +import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -172,8 +172,8 @@ import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import java.net.URI; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -918,6 +918,19 @@ public abstract class BaseController { return ResponseEntity.status(status).build(); } + protected ResponseEntity redirectTo(String location) { + URI uri; + try { + uri = URI.create(location); + } catch (IllegalArgumentException e) { + log.error("Failed to create URI from '{}'", location, e); + throw e; + } + return ResponseEntity.status(HttpStatus.SEE_OTHER) + .location(uri) + .build(); + } + protected List getOAuth2ClientIds(UUID[] ids) throws ThingsboardException { if (ids == null) { return Collections.emptyList(); diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index e4010713d8..d2586b93e9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -195,7 +195,8 @@ public class AuthControllerTest extends AbstractControllerTest { userCredentialsDao.save(tenantId, userCredentials); doGet("/api/noauth/resetPassword?resetToken={resetToken}", this.currentResetPasswordToken) - .andExpect(status().isGone()); + .andExpect(status().isSeeOther()) + .andExpect(header().string(HttpHeaders.LOCATION, "/passwordResetLinkExpired")); JsonNode resetPasswordRequest = JacksonUtil.newObjectNode() .put("resetToken", this.currentResetPasswordToken) .put("password", "wefwefe"); @@ -229,7 +230,8 @@ public class AuthControllerTest extends AbstractControllerTest { userCredentials.setActivateTokenExpTime(System.currentTimeMillis() - 1); userCredentialsDao.save(tenantId, userCredentials); doGet("/api/noauth/activate?activateToken={activateToken}", initialActivationToken) - .andExpect(status().isGone()); + .andExpect(status().isSeeOther()) + .andExpect(header().string(HttpHeaders.LOCATION, "/activationLinkExpired")); doPost("/api/noauth/activate", JacksonUtil.newObjectNode() .put("activateToken", initialActivationToken) .put("password", "wefewe")).andExpect(status().isBadRequest()) From 8233d6c451cace5dd0412b938cd0d26648986575 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 4 Sep 2024 18:28:02 +0300 Subject: [PATCH 13/34] Include password reset and activation links ttl in the email --- .../server/controller/AuthController.java | 2 +- .../server/controller/UserController.java | 42 +++++-------------- .../entitiy/user/DefaultUserService.java | 31 +++++++++++--- .../service/entitiy/user/TbUserService.java | 5 +++ .../service/mail/DefaultMailService.java | 11 ++--- .../main/resources/templates/activation.ftl | 2 +- .../resources/templates/reset.password.ftl | 2 +- .../server/controller/AbstractWebTest.java | 5 ++- .../server/controller/AuthControllerTest.java | 12 +++--- .../common/data/UserActivationLink.java | 19 +++++++++ .../common/data/security/UserCredentials.java | 7 +++- .../rule/engine/api/MailService.java | 6 +-- 12 files changed, 87 insertions(+), 57 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java 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 a52a80f9f0..17f7930eff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -159,7 +159,7 @@ public class AuthController extends BaseController { String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl, userCredentials.getResetToken()); - mailService.sendResetPasswordEmailAsync(resetUrl, email); + mailService.sendResetPasswordEmailAsync(resetUrl, userCredentials.getResetTokenTtl(), email); } catch (Exception e) { log.warn("Error occurred: {}", e.getMessage()); } 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 823129d59b..a71bd4dd38 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -43,6 +43,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserActivationLink; import org.thingsboard.server.common.data.UserEmailInfo; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -85,7 +86,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; import static org.thingsboard.server.controller.ControllerConstants.ALARM_ID_PARAM_DESCRIPTION; @@ -119,7 +119,6 @@ public class UserController extends BaseController { public static final String USER_ID = "userId"; public static final String PATHS = "paths"; public static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; - public static final String ACTIVATE_URL_PATTERN = "%s/api/noauth/activate?activateToken=%s"; public static final String MOBILE_TOKEN_HEADER = "X-Mobile-Token"; @Value("${security.user_token_access_enabled}") @@ -226,11 +225,12 @@ public class UserController extends BaseController { @Parameter(description = "Email of the user", required = true) @RequestParam(value = "email") String email, HttpServletRequest request) throws ThingsboardException { - User user = checkNotNull(userService.findUserByEmail(getCurrentUser().getTenantId(), email)); - accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, user.getId(), user); + SecurityUser securityUser = getCurrentUser(); + User user = checkNotNull(userService.findUserByEmail(securityUser.getTenantId(), email)); + accessControlService.checkPermission(securityUser, Resource.USER, Operation.READ, user.getId(), user); - ActivationLink activationLink = getActivationLink(user.getId(), request); - mailService.sendActivationEmail(activationLink.value(), email); + UserActivationLink activationLink = tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), user.getId(), request); + mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), email); } @ApiOperation(value = "Get activation link (getActivationLink)", @@ -250,13 +250,14 @@ public class UserController extends BaseController { "The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/user/{userId}/activationLinkInfo") - public ActivationLink getActivationLinkInfo(@Parameter(description = USER_ID_PARAM_DESCRIPTION) - @PathVariable(USER_ID) String strUserId, - HttpServletRequest request) throws ThingsboardException { + public UserActivationLink getActivationLinkInfo(@Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + HttpServletRequest request) throws ThingsboardException { checkParameter(USER_ID, strUserId); UserId userId = new UserId(toUUID(strUserId)); checkUserId(userId, Operation.READ); - return getActivationLink(userId, request); + SecurityUser securityUser = getCurrentUser(); + return tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request); } @ApiOperation(value = "Delete User (deleteUser)", @@ -598,31 +599,10 @@ public class UserController extends BaseController { userService.removeMobileSession(user.getTenantId(), mobileToken); } - private ActivationLink getActivationLink(UserId userId, HttpServletRequest request) throws ThingsboardException { - TenantId tenantId = getTenantId(); - UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); - if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { - long ttl = userCredentials.getActivationTokenTtl(); - if (ttl < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration - userCredentials = userService.generateUserActivationToken(userCredentials); - userCredentials = userService.saveUserCredentials(tenantId, userCredentials); - ttl = userCredentials.getActivationTokenTtl(); - log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userId); - } - String baseUrl = systemSecurityService.getBaseUrl(tenantId, getCurrentUser().getCustomerId(), request); - String link = String.format(ACTIVATE_URL_PATTERN, baseUrl, userCredentials.getActivateToken()); - return new ActivationLink(link, ttl); - } else { - throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - } - } - private void checkNotReserved(String strType, UserSettingsType type) throws ThingsboardException { if (type.isReserved()) { throw new ThingsboardException("Settings with type: " + strType + " are reserved for internal use!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); } } - record ActivationLink(String value, long ttlMs) {} - } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java index 3b86f9acfe..ad4b7c097e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java @@ -22,7 +22,9 @@ import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserActivationLink; 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.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -33,7 +35,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.security.system.SystemSecurityService; -import static org.thingsboard.server.controller.UserController.ACTIVATE_URL_PATTERN; +import java.util.concurrent.TimeUnit; @Service @TbCoreComponent @@ -53,12 +55,9 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse boolean sendEmail = tbUser.getId() == null && sendActivationMail; User savedUser = checkNotNull(userService.saveUser(tenantId, tbUser)); if (sendEmail) { - UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, savedUser.getId()); - String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request); - String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, userCredentials.getActivateToken()); - String email = savedUser.getEmail(); + UserActivationLink activationLink = getActivationLink(tenantId, customerId, savedUser.getId(), request); try { - mailService.sendActivationEmail(activateUrl, email); + mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), savedUser.getEmail()); } catch (ThingsboardException e) { userService.deleteUser(tenantId, savedUser); throw e; @@ -86,4 +85,24 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse throw e; } } + + @Override + public UserActivationLink getActivationLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException { + UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId); + if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { + long ttl = userCredentials.getActivationTokenTtl(); + if (ttl < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration + userCredentials = userService.generateUserActivationToken(userCredentials); + userCredentials = userService.saveUserCredentials(tenantId, userCredentials); + ttl = userCredentials.getActivationTokenTtl(); + log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userId); + } + String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request); + String link = baseUrl + "/api/noauth/activate?activateToken=" + userCredentials.getActivateToken(); + return new UserActivationLink(link, ttl); + } else { + throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java index 388db9df40..e7689692a4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java @@ -16,14 +16,19 @@ package org.thingsboard.server.service.entitiy.user; import jakarta.servlet.http.HttpServletRequest; +import org.thingsboard.server.common.data.UserActivationLink; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; public interface TbUserService { User save(TenantId tenantId, CustomerId customerId, User tbUser, boolean sendActivationMail, HttpServletRequest request, User user) throws ThingsboardException; void delete(TenantId tenantId, CustomerId customerId, User user, User responsibleUser) throws ThingsboardException; + + UserActivationLink getActivationLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException; + } 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 3577683729..1603720450 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 @@ -160,12 +160,12 @@ public class DefaultMailService implements MailService { } @Override - public void sendActivationEmail(String activationLink, String email) throws ThingsboardException { - + public void sendActivationEmail(String activationLink, long ttlMs, String email) throws ThingsboardException { String subject = messages.getMessage("activation.subject", null, Locale.US); Map model = new HashMap<>(); model.put("activationLink", activationLink); + model.put("activationLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); model.put(TARGET_EMAIL, email); String message = mergeTemplateIntoString("activation.ftl", model); @@ -188,12 +188,13 @@ public class DefaultMailService implements MailService { } @Override - public void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException { + public void sendResetPasswordEmail(String passwordResetLink, long ttlMs, String email) throws ThingsboardException { String subject = messages.getMessage("reset.password.subject", null, Locale.US); Map model = new HashMap<>(); model.put("passwordResetLink", passwordResetLink); + model.put("passwordResetLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); model.put(TARGET_EMAIL, email); String message = mergeTemplateIntoString("reset.password.ftl", model); @@ -202,10 +203,10 @@ public class DefaultMailService implements MailService { } @Override - public void sendResetPasswordEmailAsync(String passwordResetLink, String email) { + public void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, String email) { passwordResetExecutorService.execute(() -> { try { - this.sendResetPasswordEmail(passwordResetLink, email); + this.sendResetPasswordEmail(passwordResetLink, ttlMs, email); } catch (Exception e) { log.error("Error occurred: {} ", e.getMessage()); } diff --git a/application/src/main/resources/templates/activation.ftl b/application/src/main/resources/templates/activation.ftl index ce51930521..d5c148a56b 100644 --- a/application/src/main/resources/templates/activation.ftl +++ b/application/src/main/resources/templates/activation.ftl @@ -88,7 +88,7 @@ background-color: #f6f6f6; - To confirm your email address and choose a password, just click the button below. + To confirm your email address and choose a password, just click the button below. The link will expire in ${activationLinkTtlInHours} hours. diff --git a/application/src/main/resources/templates/reset.password.ftl b/application/src/main/resources/templates/reset.password.ftl index 3f36c922f0..6b2fa5e8bf 100644 --- a/application/src/main/resources/templates/reset.password.ftl +++ b/application/src/main/resources/templates/reset.password.ftl @@ -93,7 +93,7 @@ background-color: #f6f6f6; - Click below in order to proceed password reset procedure. + Click below in order to proceed password reset procedure. The link will expire in ${passwordResetLinkTtlInHours} hours. diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index eba04e7b5e..af20c71e5b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -145,6 +145,7 @@ import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; @@ -340,7 +341,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { currentActivateToken = activationLink.split("=")[1]; return null; } - }).when(mailService).sendActivationEmail(anyString(), anyString()); + }).when(mailService).sendActivationEmail(anyString(), anyLong(), anyString()); Mockito.doAnswer(new Answer() { public Void answer(InvocationOnMock invocation) { @@ -349,7 +350,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { currentResetPasswordToken = passwordResetLink.split("=")[1]; return null; } - }).when(mailService).sendResetPasswordEmailAsync(anyString(), anyString()); + }).when(mailService).sendResetPasswordEmailAsync(anyString(), anyLong(), anyString()); } @After diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index d2586b93e9..817ded33e7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -26,10 +26,10 @@ import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserActivationLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; -import org.thingsboard.server.controller.UserController.ActivationLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.user.UserCredentialsDao; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -222,7 +222,7 @@ public class AuthControllerTest extends AbstractControllerTest { assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L)); String initialActivationLink = getActivationLink(user); String initialActivationToken = StringUtils.substringAfterLast(initialActivationLink, "activateToken="); - ActivationLink activationLinkInfo = getActivationLinkInfo(user); + UserActivationLink activationLinkInfo = getActivationLinkInfo(user); assertThat(TimeUnit.MILLISECONDS.toHours(activationLinkInfo.ttlMs())).isCloseTo(ttl, within(1L)); assertThat(activationLinkInfo.value()).isEqualTo(initialActivationLink); @@ -238,7 +238,7 @@ public class AuthControllerTest extends AbstractControllerTest { .andExpect(jsonPath("$.message", is("Activation token expired"))); // checking that activation link is regenerated when requested - ActivationLink regeneratedActivationLink = getActivationLinkInfo(user); + UserActivationLink regeneratedActivationLink = getActivationLinkInfo(user); assertThat(regeneratedActivationLink.value()).isNotEqualTo(initialActivationLink); assertThat(TimeUnit.MILLISECONDS.toHours(regeneratedActivationLink.ttlMs())).isCloseTo(ttl, within(1L)); @@ -252,7 +252,7 @@ public class AuthControllerTest extends AbstractControllerTest { userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10)); userCredentialsDao.save(tenantId, userCredentials); - ActivationLink newActivationLink = getActivationLinkInfo(user); + UserActivationLink newActivationLink = getActivationLinkInfo(user); assertThat(newActivationLink.value()).isNotEqualTo(regeneratedActivationLink.value()); assertThat(TimeUnit.MILLISECONDS.toHours(newActivationLink.ttlMs())).isCloseTo(ttl, within(1L)); String newActivationToken = StringUtils.substringAfterLast(newActivationLink.value(), "activateToken="); @@ -281,8 +281,8 @@ public class AuthControllerTest extends AbstractControllerTest { return doGet("/api/user/" + user.getId() + "/activationLink", String.class); } - private ActivationLink getActivationLinkInfo(User user) throws Exception { - return doGet("/api/user/" + user.getId() + "/activationLinkInfo", ActivationLink.class); + private UserActivationLink getActivationLinkInfo(User user) throws Exception { + return doGet("/api/user/" + user.getId() + "/activationLinkInfo", UserActivationLink.class); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java b/common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java new file mode 100644 index 0000000000..f532a9b066 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2024 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.common.data; + +public record UserActivationLink(String value, long ttlMs) { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java index f6e23b9e1a..1104ae2949 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java @@ -62,7 +62,12 @@ public class UserCredentials extends BaseDataWithAdditionalInfo resetTokenExpTime; + return getResetTokenTtl() == 0; + } + + @JsonIgnore + public long getResetTokenTtl() { + return resetTokenExpTime != null ? Math.max(resetTokenExpTime - System.currentTimeMillis(), 0) : 0; } } 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 ecaf7ef3f9..043181a710 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 @@ -32,13 +32,13 @@ public interface MailService { void sendTestMail(JsonNode config, String email) throws ThingsboardException; - void sendActivationEmail(String activationLink, String email) throws ThingsboardException; + void sendActivationEmail(String activationLink, long ttlMs, String email) throws ThingsboardException; void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException; - void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException; + void sendResetPasswordEmail(String passwordResetLink, long ttlMs, String email) throws ThingsboardException; - void sendResetPasswordEmailAsync(String passwordResetLink, String email); + void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, String email); void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException; From 620322e3f64ca4a0e4056901bbcdd9ff35282399 Mon Sep 17 00:00:00 2001 From: rusikv Date: Thu, 5 Sep 2024 12:02:42 +0300 Subject: [PATCH 14/34] UI: expiration message for activation link dialog, expired activation or password reset links redirect to message view about expiration --- ui-ngx/src/app/core/http/user.service.ts | 6 ++- .../activation-link-dialog.component.html | 2 +- .../user/activation-link-dialog.component.ts | 18 +++---- .../pages/user/add-user-dialog.component.ts | 8 ++-- .../pages/user/users-table-config.resolver.ts | 6 +-- .../app/modules/login/login-routing.module.ts | 20 ++++++++ ui-ngx/src/app/modules/login/login.module.ts | 4 +- .../pages/login/link-expired.component.html | 40 ++++++++++++++++ .../pages/login/link-expired.component.scss | 32 +++++++++++++ .../pages/login/link-expired.component.ts | 47 +++++++++++++++++++ ui-ngx/src/app/shared/models/user.model.ts | 5 ++ .../assets/locale/locale.constant-en_US.json | 8 +++- 12 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 ui-ngx/src/app/modules/login/pages/login/link-expired.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts diff --git a/ui-ngx/src/app/core/http/user.service.ts b/ui-ngx/src/app/core/http/user.service.ts index 1d5ce28998..695bae3cc1 100644 --- a/ui-ngx/src/app/core/http/user.service.ts +++ b/ui-ngx/src/app/core/http/user.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; -import { User, UserEmailInfo } from '@shared/models/user.model'; +import { ActivationLinkInfo, User, UserEmailInfo } from '@shared/models/user.model'; import { Observable } from 'rxjs'; import { HttpClient, HttpParams } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -77,6 +77,10 @@ export class UserService { {...{responseType: 'text'}, ...defaultHttpOptionsFromConfig(config)}); } + public getActivationLinkInfo(userId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/user/${userId}/activationLinkInfo`, defaultHttpOptionsFromConfig(config)); + } + public sendActivationEmail(email: string, config?: RequestConfig) { const encodeEmail = encodeURIComponent(email); return this.http.post(`/api/user/sendActivationMail?email=${encodeEmail}`, null, defaultHttpOptionsFromConfig(config)); diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html index cabb688525..d03b50418d 100644 --- a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html @@ -30,7 +30,7 @@
- +
{{ activationLink }}
+ + +
diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss new file mode 100644 index 0000000000..323437e66b --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2024 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 '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + .tb-expired-link-content { + background-color: #eee; + .tb-expired-link-card { + letter-spacing: 0.15px; + line-height: 24px; + padding: 24px; + @media #{$mat-gt-xs} { + width: 486px !important; + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts new file mode 100644 index 0000000000..d394e426dd --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts @@ -0,0 +1,47 @@ +/// +/// Copyright © 2016-2024 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 { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { ActivatedRoute, Router } from '@angular/router'; + +@Component({ + selector: 'tb-link-expired', + templateUrl: './link-expired.component.html', + styleUrls: ['./link-expired.component.scss'] +}) +export class LinkExpiredComponent extends PageComponent { + + isPasswordLinkExpired: boolean; + title: string; + message: string; + + constructor(protected store: Store, + private route: ActivatedRoute, + private router: Router) { + super(store); + this.isPasswordLinkExpired = this.route.snapshot.data.passwordLinkExpired; + this.title = this.isPasswordLinkExpired ? 'login.reset-password-link-expired' : 'login.activation-link-expired'; + this.message = this.isPasswordLinkExpired ? 'login.reset-password-link-expired-message' : + 'login.activation-link-expired-message'; + } + + navigateToLoginPage() { + this.router.navigateByUrl('login'); + } +} diff --git a/ui-ngx/src/app/shared/models/user.model.ts b/ui-ngx/src/app/shared/models/user.model.ts index 5b2cdec08b..a17cf040a8 100644 --- a/ui-ngx/src/app/shared/models/user.model.ts +++ b/ui-ngx/src/app/shared/models/user.model.ts @@ -44,6 +44,11 @@ export const activationMethodTranslations = new Map( ] ); +export interface ActivationLinkInfo { + value: string; + ttlMs: number; +} + export interface AuthUser { sub: string; scopes: string[]; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 24d2ab5511..204e44f7be 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4009,7 +4009,11 @@ "email-auth-description": "A security code has been sent to your email address at {{contact}}.", "email-auth-placeholder": "Email code", "backup-code-auth-description": "Please enter one of your backup codes.", - "backup-code-auth-placeholder": "Backup code" + "backup-code-auth-placeholder": "Backup code", + "activation-link-expired": "Activation link has expired", + "activation-link-expired-message": "The link to activate your profile has expired. You can return to the login page to receive a new email.", + "reset-password-link-expired": "Password reset link has expired", + "reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email." }, "markdown": { "edit": "Edit", @@ -5629,7 +5633,7 @@ "display-activation-link": "Display activation link", "send-activation-mail": "Send activation mail", "activation-link": "User activation link", - "activation-link-text": "In order to activate user use the following activation link :", + "activation-link-text": "In order to activate user use the following activation link (expires in {{activationLinkTtl}}) :", "copy-activation-link": "Copy activation link", "activation-link-copied-message": "User activation link has been copied to clipboard", "details": "Details", From 7c442b000af912499df7b9f54ec9699bdc2c2690 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 6 Sep 2024 15:26:13 +0300 Subject: [PATCH 15/34] UI: Minor inmprovement for SCADA system --- .../widget/lib/scada/scada-symbol.models.ts | 9 +- .../home/pages/admin/admin-routing.module.ts | 3 +- ...scada-symbol-property-panel.component.html | 1 + .../save-widget-type-as-dialog.component.html | 1 + .../pages/widget/widget-library.module.ts | 4 +- .../widgets-bundle-dialog.component.html | 53 +++++++++++ .../widgets-bundle-dialog.component.scss | 22 +++++ .../widget/widgets-bundle-dialog.component.ts | 95 +++++++++++++++++++ .../widget/widgets-bundle.component.html | 2 +- .../pages/widget/widgets-bundle.component.ts | 9 +- .../image/image-gallery.component.ts | 16 +++- .../widgets-bundle-select.component.html | 7 +- .../widgets-bundle-select.component.ts | 57 ++++++++++- .../app/shared/models/widgets-bundle.model.ts | 3 +- .../assets/locale/locale.constant-en_US.json | 3 +- 15 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index 3c4e32abf7..bb6cc17869 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -191,10 +191,10 @@ export interface ScadaSymbolMetadata { properties: ScadaSymbolProperty[]; } -export const emptyMetadata = (): ScadaSymbolMetadata => ({ +export const emptyMetadata = (width?: number, height?: number): ScadaSymbolMetadata => ({ title: '', - widgetSizeX: 3, - widgetSizeY: 3, + widgetSizeX: width ? width/100 : 3, + widgetSizeY: height ? height/100 : 3, tags: [], behavior: [], properties: [] @@ -255,7 +255,8 @@ const parseScadaSymbolMetadataFromDom = (svgDoc: Document): ScadaSymbolMetadata if (elements.length) { return JSON.parse(elements[0].textContent); } else { - return emptyMetadata(); + const viewBox = svgDoc.getElementsByTagName('svg')[0].viewBox.baseVal; + return emptyMetadata(viewBox.width, viewBox.height); } } catch (_e) { console.error(_e); diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 7d3e9983f8..b4a4d01326 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -114,7 +114,8 @@ const routes: Routes = [ data: { auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'scada.symbols', - imageSubType: ResourceSubType.SCADA_SYMBOL + imageSubType: ResourceSubType.SCADA_SYMBOL, + editOnRowClick: true } }, { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html index 9fd461c229..15322b38d4 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html @@ -136,6 +136,7 @@
scada.property.disable-on-property
+ {{ prop }} diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html index 8d0f6bd6f5..804730dbfe 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -39,6 +39,7 @@
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts index 1ee25529b0..80438b4f4d 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts @@ -28,6 +28,7 @@ import { WidgetTypeComponent } from '@home/pages/widget/widget-type.component'; import { WidgetTypeTabsComponent } from '@home/pages/widget/widget-type-tabs.component'; import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type-autocomplete.component'; +import { WidgetsBundleDialogComponent } from '@home/pages/widget/widgets-bundle-dialog.component'; @NgModule({ declarations: [ @@ -39,7 +40,8 @@ import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type- SelectWidgetTypeDialogComponent, SaveWidgetTypeAsDialogComponent, WidgetTypeTabsComponent, - WidgetsBundleTabsComponent + WidgetsBundleTabsComponent, + WidgetsBundleDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html new file mode 100644 index 0000000000..592be51486 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html @@ -0,0 +1,53 @@ + +
+ +

widgets-bundle.add

+ + +
+ + +
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss new file mode 100644 index 0000000000..0556c6bd25 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2024 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. + */ +:host ::ng-deep { + tb-widgets-bundle { + .mat-padding { + padding: 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts new file mode 100644 index 0000000000..1f0c963af5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts @@ -0,0 +1,95 @@ +/// +/// Copyright © 2016-2024 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 { + AfterViewInit, + Component, + ComponentFactoryResolver, + Inject, + Injector, + SkipSelf, + ViewChild +} from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { TenantProfileService } from '@core/http/tenant-profile.service'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { WidgetsBundleComponent } from '@home/pages/widget/widgets-bundle.component'; +import { WidgetService } from '@core/http/widget.service'; + +export interface WidgetsBundleDialogData { + widgetsBundle: WidgetsBundle; +} + +@Component({ + selector: 'tb-widgets-bundle-dialog', + templateUrl: './widgets-bundle-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: WidgetsBundleDialogComponent}], + styleUrls: ['widgets-bundle-dialog.component.scss'] +}) +export class WidgetsBundleDialogComponent extends + DialogComponent implements ErrorStateMatcher, AfterViewInit { + + widgetsBundle: WidgetsBundle; + + submitted = false; + + @ViewChild('widgetsBundleComponent', {static: true}) widgetsBundleComponent: WidgetsBundleComponent; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: WidgetsBundleDialogData, + public dialogRef: MatDialogRef, + private componentFactoryResolver: ComponentFactoryResolver, + private injector: Injector, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private widgetsService: WidgetService) { + super(store, router, dialogRef); + this.widgetsBundle = this.data.widgetsBundle; + } + + ngAfterViewInit(): void { + setTimeout(() => { + this.widgetsBundleComponent.entityForm.markAsDirty(); + }, 0); + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.widgetsBundleComponent.entityForm.valid) { + this.widgetsBundle = {...this.widgetsBundle, ...this.widgetsBundleComponent.entityFormValue()}; + this.widgetsService.saveWidgetsBundle(this.widgetsBundle).subscribe((widgetBundle) => { + this.dialogRef.close(widgetBundle); + }); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html index f95d085688..df66eee579 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+