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;