Browse Source

Merge pull request #11271 from thingsboard/feature/activation-link-ttl

TTL for password reset and user activation links
pull/11617/head
Viacheslav Klimov 2 years ago
committed by GitHub
parent
commit
f030959b08
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 19
      application/src/main/data/upgrade/3.7.0/schema_update.sql
  2. 6
      application/src/main/java/org/thingsboard/server/controller/AdminController.java
  3. 126
      application/src/main/java/org/thingsboard/server/controller/AuthController.java
  4. 25
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  5. 2
      application/src/main/java/org/thingsboard/server/controller/ImageController.java
  6. 3
      application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java
  7. 69
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  8. 32
      application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java
  9. 5
      application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java
  10. 1
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java
  11. 11
      application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
  12. 10
      application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java
  13. 8
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
  14. 82
      application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
  15. 6
      application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java
  16. 2
      application/src/main/resources/templates/activation.ftl
  17. 2
      application/src/main/resources/templates/reset.password.ftl
  18. 5
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  19. 184
      application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
  20. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  21. 19
      common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java
  22. 104
      common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java
  23. 25
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java
  24. 2
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  25. 10
      dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java
  26. 81
      dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java
  27. 26
      dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java
  28. 29
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  29. 2
      dao/src/main/resources/sql/schema-entities.sql
  30. 2
      dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java
  31. 6
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java
  32. 6
      ui-ngx/src/app/core/http/user.service.ts
  33. 38
      ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html
  34. 3
      ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts
  35. 2
      ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html
  36. 18
      ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts
  37. 8
      ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts
  38. 6
      ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts
  39. 20
      ui-ngx/src/app/modules/login/login-routing.module.ts
  40. 4
      ui-ngx/src/app/modules/login/login.module.ts
  41. 40
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.html
  42. 32
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss
  43. 47
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts
  44. 5
      ui-ngx/src/app/shared/models/settings.models.ts
  45. 5
      ui-ngx/src/app/shared/models/user.model.ts
  46. 14
      ui-ngx/src/assets/locale/locale.constant-en_US.json

19
application/src/main/data/upgrade/3.7.0/schema_update.sql

@ -195,4 +195,21 @@ $$
END
$$;
-- OAUTH2 UPDATE END
-- OAUTH2 UPDATE END
-- 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

6
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;
}

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

@ -21,17 +21,15 @@ 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;
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;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.cache.limits.RateLimitService;
@ -48,6 +46,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;
@ -59,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")
@ -75,6 +71,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,9 +79,8 @@ 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)
public @ResponseBody
User getUser() throws ThingsboardException {
@GetMapping(value = "/auth/user")
public User getUser() throws ThingsboardException {
SecurityUser securityUser = getCurrentUser();
return userService.findUserById(securityUser.getTenantId(), securityUser.getId());
}
@ -92,8 +88,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)
@ResponseStatus(value = HttpStatus.OK)
@PostMapping(value = "/auth/logout")
public void logout(HttpServletRequest request) throws ThingsboardException {
logLogoutAction(request);
}
@ -101,8 +96,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,46 +119,34 @@ 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();
}
@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'.")
@RequestMapping(value = "/noauth/activate", params = {"activateToken"}, method = RequestMethod.GET)
public ResponseEntity<String> checkActivateToken(
"If token is not valid, returns '409 Conflict'. " +
"If token is expired, redirects to error page.")
@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) {
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 redirectTo("/activationLinkExpired");
}
return new ResponseEntity<>(headers, responseStatus);
return redirectTo("/login/createPassword?activateToken=" + activateToken);
}
@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,
@ -177,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());
}
@ -186,32 +168,22 @@ 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'.")
@RequestMapping(value = "/noauth/resetPassword", params = {"resetToken"}, method = RequestMethod.GET)
public ResponseEntity<String> checkResetToken(
"If token is not valid, returns '409 Conflict'. " +
"If token is expired, redirects to error page.")
@GetMapping(value = "/noauth/resetPassword", params = {"resetToken"})
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) {
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;
if (userCredentials == null) {
return response(HttpStatus.CONFLICT);
} else if (userCredentials.isResetTokenExpired()) {
return redirectTo("/passwordResetLinkExpired");
}
if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) {
return response(HttpStatus.TOO_MANY_REQUESTS);
}
return new ResponseEntity<>(headers, responseStatus);
return redirectTo("/login/resetPassword?resetToken=" + resetToken);
}
@ApiOperation(value = "Activate User",
@ -220,15 +192,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 +227,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 +246,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());

25
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;
@ -132,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;
@ -170,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;
@ -191,7 +193,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*/
@ -912,6 +914,23 @@ public abstract class BaseController {
}
}
protected <T> ResponseEntity<T> response(HttpStatus status) {
return ResponseEntity.status(status).build();
}
protected <T> ResponseEntity<T> 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<OAuth2ClientId> getOAuth2ClientIds(UUID[] ids) throws ThingsboardException {
if (ids == null) {
return Collections.emptyList();

2
application/src/main/java/org/thingsboard/server/controller/ImageController.java

@ -312,7 +312,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);
}
}

3
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);
}
}

69
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;
@ -44,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;
@ -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}")
@ -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());
@ -230,45 +225,39 @@ 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);
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);
}
UserActivationLink activationLink = tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), user.getId(), request);
mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), 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 {
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 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));
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);
SecurityUser securityUser = getCurrentUser();
return tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request);
}
@ApiOperation(value = "Delete User (deleteUser)",
@ -411,7 +400,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));

32
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,13 +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;
@ -87,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);
}
}
}

5
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;
}

1
application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java

@ -92,4 +92,5 @@ public class DefaultCacheCleanupService implements CacheCleanupService {
}
cacheManager.getCacheNames().forEach(this::clearCacheByName);
}
}

11
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<String, Object> 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<String, Object> 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());
}

10
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<String, JwtPair, MobileSecretEvictEvent> 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<String, Jw
public void handleEvictEvent(MobileSecretEvictEvent event) {
cache.evict(event.getSecret());
}
}

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

@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.MfaAuthenticationToken;
@ -58,6 +59,7 @@ import java.util.UUID;
public class RestAuthenticationProvider implements AuthenticationProvider {
private final SystemSecurityService systemSecurityService;
private final SecuritySettingsService securitySettingsService;
private final UserService userService;
private final CustomerService customerService;
private final TwoFactorAuthService twoFactorAuthService;
@ -66,10 +68,12 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
public RestAuthenticationProvider(final UserService userService,
final CustomerService customerService,
final SystemSecurityService systemSecurityService,
SecuritySettingsService securitySettingsService,
TwoFactorAuthService twoFactorAuthService) {
this.userService = userService;
this.customerService = customerService;
this.systemSecurityService = systemSecurityService;
this.securitySettingsService = securitySettingsService;
this.twoFactorAuthService = twoFactorAuthService;
}
@ -82,13 +86,13 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
throw new BadCredentialsException("Authentication Failed. Bad user principal.");
}
UserPrincipal userPrincipal = (UserPrincipal) principal;
UserPrincipal userPrincipal = (UserPrincipal) principal;
SecurityUser securityUser;
if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) {
String username = userPrincipal.getValue();
String password = (String) authentication.getCredentials();
SecuritySettings securitySettings = systemSecurityService.getSecuritySettings();
SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy();
if (Boolean.TRUE.equals(passwordPolicy.getForceUserToResetPasswordIfNotValid())) {
try {

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

@ -18,8 +18,8 @@ package org.thingsboard.server.service.security.system;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.passay.CharacterRule;
import org.passay.EnglishCharacterData;
@ -29,9 +29,6 @@ import org.passay.PasswordValidator;
import org.passay.Rule;
import org.passay.RuleResult;
import org.passay.WhitespaceRule;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
@ -55,6 +52,7 @@ import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettin
import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.user.UserServiceImpl;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
@ -68,76 +66,23 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE;
@Service
@Slf4j
@RequiredArgsConstructor
public class DefaultSystemSecurityService implements SystemSecurityService {
public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64;
@Autowired
private AdminSettingsService adminSettingsService;
@Autowired
private BCryptPasswordEncoder encoder;
@Autowired
private UserService userService;
@Autowired
private MailService mailService;
@Autowired
private AuditLogService auditLogService;
@Resource
private SystemSecurityService self;
@Cacheable(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'")
@Override
public SecuritySettings getSecuritySettings() {
SecuritySettings securitySettings = null;
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "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);
}
return securitySettings;
}
@CacheEvict(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'")
@Override
public SecuritySettings saveSecuritySettings(SecuritySettings 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);
}
}
private final AdminSettingsService adminSettingsService;
private final BCryptPasswordEncoder encoder;
private final UserService userService;
private final MailService mailService;
private final AuditLogService auditLogService;
private final SecuritySettingsService securitySettingsService;
@Override
public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException {
if (!encoder.matches(password, userCredentials.getPassword())) {
int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = self.getSecuritySettings();
SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 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;
}
}

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

@ -22,7 +22,6 @@ 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;
@ -30,10 +29,6 @@ import org.thingsboard.server.service.security.model.SecurityUser;
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;
@ -47,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);
}

2
application/src/main/resources/templates/activation.ftl

@ -88,7 +88,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
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.
</td>
</tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">

2
application/src/main/resources/templates/reset.password.ftl

@ -93,7 +93,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
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.
</td>
</tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">

5
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<Void>() {
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

184
application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java

@ -16,20 +16,30 @@
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.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.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.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;
@ -39,55 +49,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 +107,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 +141,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 +152,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 +174,95 @@ 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().isSeeOther())
.andExpect(header().string(HttpHeaders.LOCATION, "/passwordResetLinkExpired"));
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 = getActivationLink(user);
String initialActivationToken = StringUtils.substringAfterLast(initialActivationLink, "activateToken=");
UserActivationLink 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);
userCredentialsDao.save(tenantId, userCredentials);
doGet("/api/noauth/activate?activateToken={activateToken}", initialActivationToken)
.andExpect(status().isSeeOther())
.andExpect(header().string(HttpHeaders.LOCATION, "/activationLinkExpired"));
doPost("/api/noauth/activate", JacksonUtil.newObjectNode()
.put("activateToken", initialActivationToken)
.put("password", "wefewe")).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", is("Activation token expired")));
// checking that activation link is regenerated when requested
UserActivationLink 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);
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);
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=");
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", newActivationToken)
.put("password", "wefewe")).andExpect(status().isOk());
}
@Test
@ -173,4 +270,19 @@ public class AuthControllerTest extends AbstractControllerTest {
doGet("/login").andExpect(status().isOk());
doGet("/home").andExpect(status().isOk());
}
private void updateSecuritySettings(Consumer<SecuritySettings> updater) throws Exception {
SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class);
updater.accept(securitySettings);
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 UserActivationLink getActivationLinkInfo(User user) throws Exception {
return doGet("/api/user/" + user.getId() + "/activationLinkInfo", UserActivationLink.class);
}
}

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

@ -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);

19
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) {
}

104
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<UserCredentialsId> {
@ToString(callSuper = true)
public class UserCredentials extends BaseDataWithAdditionalInfo<UserCredentialsId> {
@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,25 @@ public class UserCredentials extends BaseData<UserCredentialsId> {
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;
@JsonIgnore
public boolean isActivationTokenExpired() {
return getActivationTokenTtl() == 0;
}
public void setActivateToken(String activateToken) {
this.activateToken = activateToken;
}
public String getResetToken() {
return resetToken;
@JsonIgnore
public long getActivationTokenTtl() {
return activateTokenExpTime != null ? Math.max(activateTokenExpTime - System.currentTimeMillis(), 0) : 0;
}
public void setResetToken(String resetToken) {
this.resetToken = resetToken;
@JsonIgnore
public boolean isResetTokenExpired() {
return getResetTokenTtl() == 0;
}
@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 long getResetTokenTtl() {
return resetTokenExpTime != null ? Math.max(resetTokenExpTime - System.currentTimeMillis(), 0) : 0;
}
}

25
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;
}

2
dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java

@ -79,7 +79,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";
/**

10
dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java

@ -50,9 +50,15 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials>
@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;
@ -72,7 +78,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials>
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();
}
@ -86,7 +94,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials>
userCredentials.setEnabled(enabled);
userCredentials.setPassword(password);
userCredentials.setActivateToken(activateToken);
userCredentials.setActivateTokenExpTime(activateTokenExpTime);
userCredentials.setResetToken(resetToken);
userCredentials.setResetTokenExpTime(resetTokenExpTime);
userCredentials.setAdditionalInfo(additionalInfo);
return userCredentials;
}

81
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);
}
}
}

26
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);
}

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

@ -63,6 +63,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;
@ -72,6 +73,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;
@ -102,6 +104,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
private final UserAuthSettingsDao userAuthSettingsDao;
private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao;
private final SecuritySettingsService securitySettingsService;
private final DataValidator<User> userValidator;
private final DataValidator<UserCredentials> userCredentialsValidator;
private final ApplicationEventPublisher eventPublisher;
@ -178,9 +181,9 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
countService.publishCountEntityEvictEvent(savedUser.getTenantId(), EntityType.USER);
UserCredentials userCredentials = new UserCredentials();
userCredentials.setEnabled(false);
userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
userCredentials.setUserId(new UserId(savedUser.getUuidId()));
userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode());
userCredentials = generateUserActivationToken(userCredentials);
userCredentialsDao.save(user.getTenantId(), userCredentials);
}
eventPublisher.publishEvent(SaveEntityEvent.builder()
@ -242,8 +245,12 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
if (userCredentials.isEnabled()) {
throw new IncorrectParameterException("User credentials already activated");
}
if (userCredentials.isActivationTokenExpired()) {
throw new IncorrectParameterException("Activation token expired");
}
userCredentials.setEnabled(true);
userCredentials.setActivateToken(null);
userCredentials.setActivateTokenExpTime(null);
userCredentials.setPassword(password);
if (userCredentials.getPassword() != null) {
updatePasswordHistory(userCredentials);
@ -263,7 +270,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
if (!userCredentials.isEnabled()) {
throw new DisabledException(String.format("User credentials not enabled [%s]", email));
}
userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
userCredentials = generatePasswordResetToken(userCredentials);
return saveUserCredentials(tenantId, userCredentials);
}
@ -273,10 +280,26 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
if (!userCredentials.isEnabled()) {
throw new IncorrectParameterException("Unable to reset password for inactive user");
}
userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
userCredentials = generatePasswordResetToken(userCredentials);
return saveUserCredentials(tenantId, userCredentials);
}
@Override
public UserCredentials generatePasswordResetToken(UserCredentials userCredentials) {
userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
int ttlHours = securitySettingsService.getSecuritySettings().getPasswordResetTokenTtl();
userCredentials.setResetTokenExpTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttlHours));
return userCredentials;
}
@Override
public UserCredentials generateUserActivationToken(UserCredentials userCredentials) {
userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
int ttlHours = securitySettingsService.getSecuritySettings().getUserActivationTokenTtl();
userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttlHours));
return userCredentials;
}
@Override
public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials) {
log.trace("Executing replaceUserCredentials [{}]", userCredentials);

2
dao/src/main/resources/sql/schema-entities.sql

@ -491,9 +491,11 @@ CREATE TABLE IF NOT EXISTS user_credentials (
id uuid NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY,
created_time bigint NOT NULL,
activate_token varchar(255) UNIQUE,
activate_token_exp_time BIGINT,
enabled boolean,
password varchar(255),
reset_token varchar(255) UNIQUE,
reset_token_exp_time BIGINT,
user_id uuid UNIQUE,
additional_info varchar DEFAULT '{}'
);

2
dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java

@ -63,7 +63,9 @@ public class JpaUserCredentialsDaoTest extends AbstractJpaDaoTest {
userCredentials.setUserId(new UserId(UUID.randomUUID()));
userCredentials.setPassword("password");
userCredentials.setActivateToken("ACTIVATE_TOKEN_" + number);
userCredentials.setActivateTokenExpTime(123L);
userCredentials.setResetToken("RESET_TOKEN_" + number);
userCredentials.setResetTokenExpTime(321L);
return userCredentialsDao.save(SYSTEM_TENANT_ID, userCredentials);
}

6
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;

6
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<ActivationLinkInfo> {
return this.http.get<ActivationLinkInfo>(`/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));

38
ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html

@ -46,6 +46,44 @@
<input matInput type="email"
formControlName="userLockoutNotificationEmail"/>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.user-activation-token-ttl</mat-label>
<input matInput type="number"
formControlName="userActivationTokenTtl"
step="1"
min="1"
max="24"/>
<mat-error *ngIf="securitySettingsFormGroup.get('userActivationTokenTtl').hasError('min')">
{{ 'admin.user-activation-token-ttl-range' | translate }}
</mat-error>
<mat-error *ngIf="securitySettingsFormGroup.get('userActivationTokenTtl').hasError('max')">
{{ 'admin.user-activation-token-ttl-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.password-reset-token-ttl</mat-label>
<input matInput type="number"
formControlName="passwordResetTokenTtl"
step="1"
min="1"
max="24"/>
<mat-error *ngIf="securitySettingsFormGroup.get('passwordResetTokenTtl').hasError('min')">
{{ 'admin.password-reset-token-ttl-range' | translate }}
</mat-error>
<mat-error *ngIf="securitySettingsFormGroup.get('passwordResetTokenTtl').hasError('max')">
{{ 'admin.password-reset-token-ttl-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.mobile-secret-key-length</mat-label>
<input matInput type="number"
formControlName="mobileSecretKeyLength"
step="1"
min="1"/>
<mat-error *ngIf="securitySettingsFormGroup.get('mobileSecretKeyLength').hasError('min')">
{{ 'admin.mobile-secret-key-length-range' | translate }}
</mat-error>
</mat-form-field>
</fieldset>
<fieldset class="fields-group">

3
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)]],

2
ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html

@ -30,7 +30,7 @@
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content tb-toast toastTarget="activationLinkDialogContent">
<div class="mat-content" fxLayout="column">
<span [innerHTML]="'user.activation-link-text' | translate: {activationLink: activationLink}"></span>
<span [innerHTML]="'user.activation-link-text' | translate: {activationLink: activationLink, activationLinkTtl: activationLinkTtl}"></span>
<div fxLayout="row" fxLayoutAlign="start center">
<pre class="tb-highlight" fxFlex><code>{{ activationLink }}</code></pre>
<button mat-icon-button

18
ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, Inject, OnInit } from '@angular/core';
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -22,29 +22,31 @@ import { TranslateService } from '@ngx-translate/core';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';
import { ActivationLinkInfo } from '@shared/models/user.model';
import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe';
export interface ActivationLinkDialogData {
activationLink: string;
activationLinkInfo: ActivationLinkInfo;
}
@Component({
selector: 'tb-activation-link-dialog',
templateUrl: './activation-link-dialog.component.html'
})
export class ActivationLinkDialogComponent extends DialogComponent<ActivationLinkDialogComponent, void> implements OnInit {
export class ActivationLinkDialogComponent extends DialogComponent<ActivationLinkDialogComponent, void> {
activationLink: string;
activationLinkTtl: string;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: ActivationLinkDialogData,
public dialogRef: MatDialogRef<ActivationLinkDialogComponent, void>,
private translate: TranslateService) {
private translate: TranslateService,
private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe) {
super(store, router, dialogRef);
this.activationLink = this.data.activationLink;
}
ngOnInit(): void {
this.activationLink = this.data.activationLinkInfo.value;
this.activationLinkTtl = this.millisecondsToTimeStringPipe.transform(this.data.activationLinkInfo.ttlMs);
}
close(): void {

8
ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts

@ -21,7 +21,7 @@ import { AppState } from '@core/core.state';
import { UntypedFormGroup } from '@angular/forms';
import { UserComponent } from '@modules/home/pages/user/user.component';
import { Authority } from '@shared/models/authority.enum';
import { ActivationMethod, activationMethodTranslations, User } from '@shared/models/user.model';
import { ActivationLinkInfo, ActivationMethod, activationMethodTranslations, User } from '@shared/models/user.model';
import { CustomerId } from '@shared/models/id/customer-id';
import { UserService } from '@core/http/user.service';
import { Observable } from 'rxjs';
@ -88,7 +88,7 @@ export class AddUserDialogComponent extends DialogComponent<AddUserDialogCompone
this.userService.saveUser(this.user, sendActivationEmail).subscribe(
(user) => {
if (this.activationMethod === ActivationMethod.DISPLAY_ACTIVATION_LINK) {
this.userService.getActivationLink(user.id.id).subscribe(
this.userService.getActivationLinkInfo(user.id.id).subscribe(
(activationLink) => {
this.displayActivationLink(activationLink).subscribe(
() => {
@ -105,13 +105,13 @@ export class AddUserDialogComponent extends DialogComponent<AddUserDialogCompone
}
}
displayActivationLink(activationLink: string): Observable<void> {
displayActivationLink(activationLinkInfo: ActivationLinkInfo): Observable<void> {
return this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData,
void>(ActivationLinkDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
activationLink
activationLinkInfo
}
}).afterClosed();
}

6
ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts

@ -193,14 +193,14 @@ export class UsersTableConfigResolver implements Resolve<EntityTableConfig<User>
if ($event) {
$event.stopPropagation();
}
this.userService.getActivationLink(user.id.id).subscribe(
(activationLink) => {
this.userService.getActivationLinkInfo(user.id.id).subscribe(
(activationLinkInfo) => {
this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData,
void>(ActivationLinkDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
activationLink
activationLinkInfo
}
});
}

20
ui-ngx/src/app/modules/login/login-routing.module.ts

@ -24,6 +24,7 @@ import { ResetPasswordComponent } from '@modules/login/pages/login/reset-passwor
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { Authority } from '@shared/models/authority.enum';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
const routes: Routes = [
{
@ -81,6 +82,25 @@ const routes: Routes = [
module: 'public'
},
canActivate: [AuthGuard]
},
{
path: 'activationLinkExpired',
component: LinkExpiredComponent,
data: {
title: 'login.activation-link-expired',
module: 'public'
},
canActivate: [AuthGuard]
},
{
path: 'passwordResetLinkExpired',
component: LinkExpiredComponent,
data: {
title: 'login.reset-password-link-expired',
module: 'public',
passwordLinkExpired: true
},
canActivate: [AuthGuard]
}
];

4
ui-ngx/src/app/modules/login/login.module.ts

@ -24,6 +24,7 @@ import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-
import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component';
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
@NgModule({
declarations: [
@ -31,7 +32,8 @@ import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-fact
ResetPasswordRequestComponent,
ResetPasswordComponent,
CreatePasswordComponent,
TwoFactorAuthLoginComponent
TwoFactorAuthLoginComponent,
LinkExpiredComponent
],
imports: [
CommonModule,

40
ui-ngx/src/app/modules/login/pages/login/link-expired.component.html

@ -0,0 +1,40 @@
<!--
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.
-->
<div class="tb-expired-link-content mat-app-background tb-dark tb-flex row center align-center"
style="width: 100%;">
<mat-card appearance="raised" class="tb-expired-link-card">
<mat-card-header style="justify-content: center">
<mat-card-title>
<span class="mat-headline-5">{{ title | translate }}</span>
</mat-card-title>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<mat-card-content style="padding: 24px 16px">
<div>{{ message | translate }}</div>
</mat-card-content>
<mat-card-actions class="tb-flex row center">
<button mat-raised-button color="accent"
[disabled]="(isLoading$ | async)"
(click)="navigateToLoginPage()">
{{ 'login.login' | translate }}
</button>
</mat-card-actions>
</mat-card>
</div>

32
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;
}
}
}
}

47
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<AppState>,
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');
}
}

5
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 {

5
ui-ngx/src/app/shared/models/user.model.ts

@ -44,6 +44,11 @@ export const activationMethodTranslations = new Map<ActivationMethod, string>(
]
);
export interface ActivationLinkInfo {
value: string;
ttlMs: number;
}
export interface AuthUser {
sub: string;
scopes: string[];

14
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",
@ -4014,7 +4020,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",
@ -5634,7 +5644,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 <a href='{{activationLink}}' target='_blank'>activation link</a> :",
"activation-link-text": "In order to activate user use the following <a href='{{activationLink}}' target='_blank'>activation link</a> (expires in {{activationLinkTtl}}) :",
"copy-activation-link": "Copy activation link",
"activation-link-copied-message": "User activation link has been copied to clipboard",
"details": "Details",

Loading…
Cancel
Save