Browse Source

Merge pull request #14074 from AndriiLandiak/api-key

API keys
pull/14369/head
Viacheslav Klimov 11 months ago
committed by GitHub
parent
commit
93c6c005d4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 6
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  3. 62
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  4. 154
      application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java
  5. 19
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  6. 2
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  7. 5
      application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java
  8. 28
      application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java
  9. 10
      application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java
  10. 16
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
  11. 11
      application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java
  12. 22
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java
  13. 29
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java
  14. 29
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java
  15. 5
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtQueryTokenExtractor.java
  16. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/TokenExtractor.java
  17. 3
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java
  18. 27
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java
  19. 40
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java
  20. 12
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java
  21. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java
  22. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java
  23. 2
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java
  24. 3
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java
  25. 1
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java
  26. 86
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java
  27. 61
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java
  28. 87
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java
  29. 7
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java
  30. 3
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java
  31. 5
      application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java
  32. 11
      application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java
  33. 2
      application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
  34. 5
      application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java
  35. 14
      application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java
  36. 18
      application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java
  37. 18
      application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
  38. 2
      application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java
  39. 3
      application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
  40. 12
      application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java
  41. 17
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  42. 56
      application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java
  43. 78
      application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java
  44. 26
      application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java
  45. 15
      application/src/main/resources/thingsboard.yml
  46. 59
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  47. 144
      application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java
  48. 14
      application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java
  49. 21
      application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java
  50. 112
      application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java
  51. 41
      common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java
  52. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  53. 1
      common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
  54. 6
      common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java
  55. 20
      common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
  56. 18
      common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java
  57. 46
      common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java
  58. 1
      common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
  59. 61
      common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java
  60. 96
      common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java
  61. 4
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java
  62. 3
      common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java
  63. 1
      common/proto/src/main/proto/queue.proto
  64. 11
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  65. 81
      dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java
  66. 51
      dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java
  67. 46
      dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java
  68. 3
      dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java
  69. 40
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java
  70. 33
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java
  71. 35
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java
  72. 18
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java
  73. 29
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java
  74. 36
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java
  75. 163
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java
  76. 77
      dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java
  77. 36
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java
  78. 58
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java
  79. 78
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java
  80. 58
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java
  81. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java
  82. 9
      dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java
  83. 2
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  84. 4
      dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java
  85. 13
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  86. 6
      dao/src/main/resources/sql/schema-entities-idx.sql
  87. 12
      dao/src/main/resources/sql/schema-entities.sql
  88. 219
      dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java
  89. 3
      dao/src/test/resources/application-test.properties
  90. 54
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  91. 3
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  92. 4
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java
  93. 50
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java
  94. 54
      ui-ngx/src/app/core/http/api-key.service.ts
  95. 83
      ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html
  96. 49
      ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss
  97. 103
      ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts
  98. 101
      ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html
  99. 95
      ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss
  100. 77
      ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts

5
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -94,6 +94,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService;
import org.thingsboard.server.dao.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
@ -572,6 +573,10 @@ public class ActorSystemContext {
@Getter
private JobManager jobManager;
@Autowired
@Getter
private ApiKeyService apiKeyService;
@Autowired
@Getter
private OwnerService ownerService;

6
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -109,6 +109,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService;
import org.thingsboard.server.dao.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
@ -911,6 +912,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getJobManager();
}
@Override
public ApiKeyService getApiKeyService() {
return mainCtx.getApiKeyService();
}
@Override
public boolean isExternalNodeForceAck() {
return mainCtx.isExternalNodeForceAck();

62
application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java

@ -31,7 +31,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
@ -48,21 +47,23 @@ import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.AuthExceptionHandler;
import org.thingsboard.server.service.security.auth.extractor.TokenExtractor;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenProcessingFilter;
import org.thingsboard.server.service.security.auth.jwt.SkipPathRequestMatcher;
import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor;
import org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository;
import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider;
import org.thingsboard.server.service.security.auth.pat.ApiKeyTokenAuthenticationProcessingFilter;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationProvider;
import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilter;
import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter;
import org.thingsboard.server.transport.http.config.PayloadSizeFilter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
@Configuration
@EnableWebSecurity
@ -71,10 +72,13 @@ import java.util.List;
@TbCoreComponent
public class ThingsboardSecurityConfiguration {
public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization";
public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization";
public static final String AUTHORIZATION_HEADER = "X-Authorization";
public static final String AUTHORIZATION_HEADER_V2 = "Authorization";
public static final String JWT_TOKEN_QUERY_PARAM = "token";
public static final String API_KEY_HEADER_PREFIX = "ApiKey ";
public static final String BEARER_HEADER_PREFIX = "Bearer ";
public static final String DEVICE_API_ENTRY_POINT = "/api/v1/**";
public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
@ -116,6 +120,8 @@ public class ThingsboardSecurityConfiguration {
private JwtAuthenticationProvider jwtAuthenticationProvider;
@Autowired
private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider;
@Autowired
private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider;
@Autowired(required = false)
OAuth2Configuration oauth2Configuration;
@ -124,6 +130,10 @@ public class ThingsboardSecurityConfiguration {
@Qualifier("jwtHeaderTokenExtractor")
private TokenExtractor jwtHeaderTokenExtractor;
@Autowired
@Qualifier("apiKeyHeaderTokenExtractor")
private TokenExtractor apiKeyHeaderTokenExtractor;
@Autowired
private AuthenticationManager authenticationManager;
@ -139,7 +149,7 @@ public class ThingsboardSecurityConfiguration {
}
@Bean
protected FilterRegistrationBean<ShallowEtagHeaderFilter> buildEtagFilter() throws Exception {
protected FilterRegistrationBean<ShallowEtagHeaderFilter> buildEtagFilter() {
ShallowEtagHeaderFilter etagFilter = new ShallowEtagHeaderFilter();
etagFilter.setWriteWeakETag(true);
FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean
@ -150,25 +160,22 @@ public class ThingsboardSecurityConfiguration {
}
@Bean
protected RestLoginProcessingFilter buildRestLoginProcessingFilter() throws Exception {
protected RestLoginProcessingFilter buildRestLoginProcessingFilter() {
RestLoginProcessingFilter filter = new RestLoginProcessingFilter(FORM_BASED_LOGIN_ENTRY_POINT, successHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
@Bean
protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() throws Exception {
protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() {
RestPublicLoginProcessingFilter filter = new RestPublicLoginProcessingFilter(PUBLIC_LOGIN_ENTRY_POINT, successHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception {
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS));
pathsToSkip.addAll(Arrays.asList(WS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT,
PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT,
DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT));
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
@Bean
protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() {
SkipPathRequestMatcher matcher = buildSkipPathRequestMatcher();
JwtTokenAuthenticationProcessingFilter filter
= new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher);
filter.setAuthenticationManager(this.authenticationManager);
@ -176,7 +183,30 @@ public class ThingsboardSecurityConfiguration {
}
@Bean
protected RefreshTokenProcessingFilter buildRefreshTokenProcessingFilter() throws Exception {
protected ApiKeyTokenAuthenticationProcessingFilter buildApiKeyTokenAuthenticationProcessingFilter() {
SkipPathRequestMatcher matcher = buildSkipPathRequestMatcher();
ApiKeyTokenAuthenticationProcessingFilter filter =
new ApiKeyTokenAuthenticationProcessingFilter(failureHandler, apiKeyHeaderTokenExtractor, matcher);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
private SkipPathRequestMatcher buildSkipPathRequestMatcher() {
List<String> pathsToSkip = Stream.concat(
Arrays.stream(NON_TOKEN_BASED_AUTH_ENTRY_POINTS),
Stream.of(
WS_ENTRY_POINT,
TOKEN_REFRESH_ENTRY_POINT,
FORM_BASED_LOGIN_ENTRY_POINT,
PUBLIC_LOGIN_ENTRY_POINT,
DEVICE_API_ENTRY_POINT,
MAIL_OAUTH2_PROCESSING_ENTRY_POINT,
DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT)).toList();
return new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
}
@Bean
protected RefreshTokenProcessingFilter buildRefreshTokenProcessingFilter() {
RefreshTokenProcessingFilter filter = new RefreshTokenProcessingFilter(TOKEN_REFRESH_ENTRY_POINT, successHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
@ -187,6 +217,7 @@ public class ThingsboardSecurityConfiguration {
return new ProviderManager(List.of(
restAuthenticationProvider,
jwtAuthenticationProvider,
apiKeyAuthenticationProvider,
refreshTokenAuthenticationProvider
));
}
@ -233,6 +264,7 @@ public class ThingsboardSecurityConfiguration {
.addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildApiKeyTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(payloadSizeFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class)

154
application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java

@ -0,0 +1,154 @@
/**
* Copyright © 2016-2025 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.controller;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.Optional;
import java.util.UUID;
import static org.thingsboard.server.controller.ControllerConstants.API_KEY_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.API_KEY_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION;
@RestController
@TbCoreComponent
@Slf4j
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiKeyController extends BaseController {
private final ApiKeyService apiKeyService;
@ApiOperation(value = "Save API key for user (saveApiKey)",
notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey <value>'." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@PostMapping(value = "/apiKey")
public ApiKey saveApiKey(
@Parameter(description = "A JSON value representing the Api Key token.")
@RequestBody @Valid ApiKeyInfo apiKeyInfo) throws ThingsboardException {
User user = checkUserId(apiKeyInfo.getUserId(), Operation.WRITE);
apiKeyInfo.setTenantId(user.getTenantId());
checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY);
return checkNotNull(apiKeyService.saveApiKey(apiKeyInfo.getTenantId(), apiKeyInfo));
}
@ApiOperation(value = "Get User Api Keys (getUserApiKeys)",
notes = "Returns a page of api keys owned by user. " +
PAGE_DATA_PARAMETERS + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/apiKeys/{userId}")
public PageData<ApiKeyInfo> getUserApiKeys(
@Parameter(description = USER_ID_PARAM_DESCRIPTION)
@PathVariable("userId") String userIdStr,
@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
@RequestParam int pageSize,
@Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
@RequestParam int page,
@Parameter(description = API_KEY_TEXT_SEARCH_DESCRIPTION)
@RequestParam(required = false) String textSearch,
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "expirationTime", "description", "enabled"}))
@RequestParam(required = false) String sortProperty,
@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"}))
@RequestParam(required = false) String sortOrder) throws ThingsboardException {
SecurityUser securityUser = getCurrentUser();
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
UserId userId = new UserId(toUUID(userIdStr));
accessControlService.checkPermission(securityUser, Resource.API_KEY, Operation.READ);
User user = checkUserId(userId, Operation.READ);
return apiKeyService.findApiKeysByUserId(user.getTenantId(), userId, pageLink);
}
@ApiOperation(value = "Update API key Description",
notes = "Updates the description of the existing API key by apiKeyId. " +
"Only the description can be updated. " +
"Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@PutMapping("/apiKey/{id}/description")
public ApiKeyInfo updateApiKeyDescription(
@Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true)
@PathVariable UUID id,
@Parameter(description = "New description for the API key", example = "Description")
@RequestBody Optional<String> description) throws Exception {
ApiKeyId apiKeyId = new ApiKeyId(id);
ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE);
checkUserId(apiKey.getUserId(), Operation.WRITE);
apiKey.setDescription(description.orElse(null));
return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey);
}
@ApiOperation(value = "Enable or disable API key (enableApiKey)",
notes = "Updates api key with enabled = true/false. " + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@PutMapping(value = "/apiKey/{id}/enabled/{enabledValue}")
public ApiKeyInfo enableApiKey(
@Parameter(description = "Unique identifier of the API key to enable/disable", required = true)
@PathVariable UUID id,
@Parameter(description = "Enabled or disabled api key", required = true)
@PathVariable(value = "enabledValue") Boolean enabledValue) throws ThingsboardException {
ApiKeyId apiKeyId = new ApiKeyId(id);
ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE);
checkUserId(apiKey.getUserId(), Operation.WRITE);
apiKey.setEnabled(enabledValue);
return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey);
}
@ApiOperation(value = "Delete API key by ID (deleteApiKey)",
notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@DeleteMapping(value = "/apiKey/{id}")
public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException {
ApiKeyId apiKeyId = new ApiKeyId(id);
ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.DELETE);
checkUserId(apiKey.getUserId(), Operation.WRITE);
apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false);
}
}

19
application/src/main/java/org/thingsboard/server/controller/BaseController.java

@ -80,6 +80,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@ -118,6 +119,7 @@ import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.plugin.ComponentDescriptor;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
@ -158,6 +160,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService;
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.pat.ApiKeyService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.ResourceService;
@ -221,8 +224,6 @@ import static org.thingsboard.server.dao.service.Validator.validateId;
@TbCoreComponent
public abstract class BaseController {
protected static final String DASHBOARD_ID = "dashboardId";
protected static final String HOME_DASHBOARD_ID = "homeDashboardId";
protected static final String HOME_DASHBOARD_HIDE_TOOLBAR = "homeDashboardHideToolbar";
@ -389,6 +390,9 @@ public abstract class BaseController {
@Autowired
protected TbAiModelService tbAiModelService;
@Autowired
protected ApiKeyService apiKeyService;
@Value("${server.log_controller_error_stack_trace}")
@Getter
private boolean logControllerErrorStackTrace;
@ -648,6 +652,7 @@ public abstract class BaseController {
case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation);
case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation);
case AI_MODEL -> checkAiModelId(new AiModelId(entityId.getId()), operation);
case API_KEY -> checkApiKeyId(new ApiKeyId(entityId.getId()), operation);
default -> (HasId<? extends EntityId>) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation);
};
} catch (Exception e) {
@ -657,7 +662,7 @@ public abstract class BaseController {
protected <E extends HasId<I> & HasTenantId, I extends EntityId> E checkEntityId(I entityId, ThrowingBiFunction<TenantId, I, E> findingFunction, Operation operation) throws ThingsboardException {
try {
validateId((UUIDBased) entityId, "Invalid entity id");
validateId((UUIDBased) entityId, id -> "Invalid entity id");
SecurityUser user = getCurrentUser();
E entity = findingFunction.apply(user.getTenantId(), entityId);
checkNotNull(entity, entityId.getEntityType().getNormalName() + " with id [" + entityId + "] is not found");
@ -855,12 +860,16 @@ public abstract class BaseController {
return checkEntityId(settingsId, (tenantId, id) -> aiModelService.findAiModelByTenantIdAndId(tenantId, id).orElse(null), operation);
}
ApiKey checkApiKeyId(ApiKeyId apiKeyId, Operation operation) throws ThingsboardException {
return checkEntityId(apiKeyId, apiKeyService::findApiKeyById, operation);
}
protected <I extends EntityId> I emptyId(EntityType entityType) {
return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID);
}
public static Exception toException(Throwable error) {
return error != null ? (Exception.class.isInstance(error) ? (Exception) error : new Exception(error)) : null;
return error != null ? (error instanceof Exception ? (Exception) error : new Exception(error)) : null;
}
protected <E extends HasName & HasId<? extends EntityId>> void logEntityAction(SecurityUser user, EntityType entityType, E savedEntity, ActionType actionType) {
@ -939,7 +948,7 @@ public abstract class BaseController {
}
private CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
validateId(calculatedFieldId, "Invalid entity id");
validateId(calculatedFieldId, id -> "Invalid entity id");
SecurityUser user = getCurrentUser();
CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId);
checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found");

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

@ -64,6 +64,7 @@ public class ControllerConstants {
protected static final String WIDGET_TYPE_ID_PARAM_DESCRIPTION = "A string value representing the widget type id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String VC_REQUEST_ID_PARAM_DESCRIPTION = "A string value representing the version control request id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String RESOURCE_ID_PARAM_DESCRIPTION = "A string value representing the resource id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String API_KEY_ID_PARAM_DESCRIPTION = "A string value representing the api key id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String SYSTEM_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' authority.";
protected static final String SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' or 'TENANT_ADMIN' authority.";
protected static final String TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'TENANT_ADMIN' authority.";
@ -91,6 +92,7 @@ public class ControllerConstants {
protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name.";
protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name.";
protected static final String AI_MODEL_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model name, provider and model ID.";
protected static final String API_KEY_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the description.";
protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name.";
protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title.";

5
application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java

@ -31,15 +31,12 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.MobileAppBundleId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.mobile.app.MobileApp;
import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings;
import org.thingsboard.server.common.data.mobile.app.StoreInfo;
import org.thingsboard.server.common.data.oauth2.PlatformType;
import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.mobile.MobileAppService;
import org.thingsboard.server.dao.mobile.QrCodeSettingService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.mobile.secret.MobileAppSecretService;

28
application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java

@ -32,6 +32,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
@ -137,23 +138,22 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
try {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
if (exception instanceof ThingsboardException) {
ThingsboardException thingsboardException = (ThingsboardException) exception;
if (exception instanceof ThingsboardException thingsboardException) {
if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) {
handleSubscriptionException((ThingsboardException) exception, response);
handleSubscriptionException(thingsboardException, response);
} else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) {
handleDatabaseException(thingsboardException.getCause(), response);
} else {
handleThingsboardException((ThingsboardException) exception, response);
handleThingsboardException(thingsboardException, response);
}
} else if (exception instanceof TbRateLimitsException) {
handleRateLimitException(response, (TbRateLimitsException) exception);
} else if (exception instanceof TbRateLimitsException rateLimitsException) {
handleRateLimitException(response, rateLimitsException);
} else if (exception instanceof AccessDeniedException) {
handleAccessDeniedException(response);
} else if (exception instanceof AuthenticationException) {
handleAuthenticationException((AuthenticationException) exception, response);
} else if (exception instanceof MaxPayloadSizeExceededException) {
handleMaxPayloadSizeExceededException(response, (MaxPayloadSizeExceededException) exception);
} else if (exception instanceof AuthenticationException authenticationException) {
handleAuthenticationException(authenticationException, response);
} else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) {
handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException);
} else if (exception instanceof DataAccessException e) {
handleDatabaseException(e, response);
} else {
@ -238,13 +238,13 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof AuthMethodNotSupportedException) {
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(authenticationException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof UserPasswordExpiredException) {
UserPasswordExpiredException expiredException = (UserPasswordExpiredException) authenticationException;
} else if (authenticationException instanceof UserPasswordExpiredException expiredException) {
String resetToken = expiredException.getResetToken();
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken));
} else if (authenticationException instanceof UserPasswordNotValidException) {
UserPasswordNotValidException expiredException = (UserPasswordNotValidException) authenticationException;
} else if (authenticationException instanceof UserPasswordNotValidException expiredException) {
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(expiredException.getMessage()));
} else if (authenticationException instanceof CredentialsExpiredException credentialsExpiredException) {
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(credentialsExpiredException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
} else {
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
}

10
application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java

@ -110,7 +110,7 @@ public class EntityStateSourcingListener {
case ASSET -> {
onAssetUpdate(event.getEntity(), event.getOldEntity());
}
case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> {
case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, USER -> {
tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent);
}
case RULE_CHAIN -> {
@ -178,7 +178,7 @@ public class EntityStateSourcingListener {
Asset asset = (Asset) event.getEntity();
tbClusterService.onAssetDeleted(tenantId, asset, null);
}
case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> {
case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, USER -> {
tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED);
}
case NOTIFICATION_REQUEST -> {
@ -234,10 +234,12 @@ public class EntityStateSourcingListener {
log.trace("[{}] ActionEntityEvent called: {}", tenantId, event);
switch (event.getActionType()) {
case CREDENTIALS_UPDATED -> {
if (EntityType.DEVICE.equals(event.getEntityId().getEntityType()) &&
event.getEntity() instanceof DeviceCredentials deviceCredentials) {
if (event.getEntityId().getEntityType() == EntityType.DEVICE && event.getEntity() instanceof DeviceCredentials deviceCredentials) {
tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(tenantId,
(DeviceId) event.getEntityId(), deviceCredentials), null);
} else if (event.getEntityId().getEntityType() == EntityType.USER) {
tbClusterService.broadcastEntityStateChangeEvent(event.getTenantId(), event.getEntityId(), ComponentLifecycleEvent.UPDATED);
}
}
case ASSIGNED_TO_TENANT -> {

16
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java

@ -611,7 +611,8 @@ public class DefaultTbClusterService implements TbClusterService {
EntityType.ASSET_PROFILE,
EntityType.JOB,
EntityType.TB_RESOURCE,
EntityType.CUSTOMER)
EntityType.CUSTOMER,
EntityType.USER)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
) {
@ -626,11 +627,14 @@ public class DefaultTbClusterService implements TbClusterService {
// No need to push notifications twice
tbRuleEngineServices.removeAll(tbCoreServices);
}
for (String serviceId : tbRuleEngineServices) {
TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId);
ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build();
toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null);
toRuleEngineNfs.incrementAndGet();
boolean toRuleEngine = entityType != EntityType.USER;
if (toRuleEngine) {
for (String serviceId : tbRuleEngineServices) {
TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId);
ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build();
toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null);
toRuleEngineNfs.incrementAndGet();
}
}
}

11
application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java

@ -49,21 +49,22 @@ public class DefaultTokenOutdatingService implements TokenOutdatingService {
@Override
public boolean isOutdated(String token, UserId userId) {
Claims claims = tokenFactory.parseTokenClaims(token).getBody();
Claims claims = tokenFactory.parseTokenClaims(token).getPayload();
long issueTime = claims.getIssuedAt().getTime();
String sessionId = claims.get("sessionId", String.class);
if (isTokenOutdated(issueTime, userId.toString())){
return true;
if (isTokenOutdated(issueTime, userId.toString())) {
return true;
} else {
return sessionId != null && isTokenOutdated(issueTime, sessionId);
return sessionId != null && isTokenOutdated(issueTime, sessionId);
}
}
private Boolean isTokenOutdated(long issueTime, String sessionId) {
return Optional.ofNullable(cache.get(sessionId)).map(outdatageTime -> isTokenOutdated(issueTime, outdatageTime.get())).orElse(false);
return Optional.ofNullable(cache.get(sessionId)).map(outdatedTime -> isTokenOutdated(issueTime, outdatedTime.get())).orElse(false);
}
private boolean isTokenOutdated(long issueTime, Long outdatageTime) {
return MILLISECONDS.toSeconds(issueTime) < MILLISECONDS.toSeconds(outdatageTime);
}
}

22
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java → application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java

@ -13,32 +13,36 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.jwt.extractor;
package org.thingsboard.server.service.security.auth.extractor;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.config.ThingsboardSecurityConfiguration;
@Component(value="jwtHeaderTokenExtractor")
public class JwtHeaderTokenExtractor implements TokenExtractor {
public static final String HEADER_PREFIX = "Bearer ";
public abstract class AbstractHeaderTokenExtractor implements TokenExtractor {
private final String headerPrefix;
protected AbstractHeaderTokenExtractor(String headerPrefix) {
this.headerPrefix = headerPrefix;
}
@Override
public String extract(HttpServletRequest request) {
String header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM);
String header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER);
if (StringUtils.isBlank(header)) {
header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2);
header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2);
if (StringUtils.isBlank(header)) {
throw new AuthenticationServiceException("Authorization header cannot be blank!");
}
}
if (header.length() < HEADER_PREFIX.length()) {
if (header.length() < headerPrefix.length()) {
throw new AuthenticationServiceException("Invalid authorization header size.");
}
return header.substring(HEADER_PREFIX.length(), header.length());
return header.substring(headerPrefix.length());
}
}

29
application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.extractor;
import org.springframework.stereotype.Component;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
@Component(value = "apiKeyHeaderTokenExtractor")
public class ApiKeyHeaderTokenExtractor extends AbstractHeaderTokenExtractor {
public ApiKeyHeaderTokenExtractor() {
super(API_KEY_HEADER_PREFIX);
}
}

29
application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.extractor;
import org.springframework.stereotype.Component;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
@Component(value = "jwtHeaderTokenExtractor")
public class JwtHeaderTokenExtractor extends AbstractHeaderTokenExtractor {
public JwtHeaderTokenExtractor() {
super(BEARER_HEADER_PREFIX);
}
}

5
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java → application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtQueryTokenExtractor.java

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.jwt.extractor;
package org.thingsboard.server.service.security.auth.extractor;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationServiceException;
@ -21,7 +21,7 @@ import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.config.ThingsboardSecurityConfiguration;
@Component(value="jwtQueryTokenExtractor")
@Component(value = "jwtQueryTokenExtractor")
public class JwtQueryTokenExtractor implements TokenExtractor {
@Override
@ -39,4 +39,5 @@ public class JwtQueryTokenExtractor implements TokenExtractor {
return token;
}
}

6
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java → application/src/main/java/org/thingsboard/server/service/security/auth/extractor/TokenExtractor.java

@ -13,10 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.jwt.extractor;
package org.thingsboard.server.service.security.auth.extractor;
import jakarta.servlet.http.HttpServletRequest;
public interface TokenExtractor {
String extract(HttpServletRequest request);
}
}

3
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java

@ -39,7 +39,7 @@ public class JwtAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();
SecurityUser securityUser = authenticate(rawAccessToken.getToken());
SecurityUser securityUser = authenticate(rawAccessToken.token());
return new JwtAuthenticationToken(securityUser);
}
@ -58,4 +58,5 @@ public class JwtAuthenticationProvider implements AuthenticationProvider {
public boolean supports(Class<?> authentication) {
return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
}
}

27
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java

@ -28,12 +28,17 @@ import org.springframework.security.web.authentication.AbstractAuthenticationPro
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.thingsboard.server.service.security.auth.JwtAuthenticationToken;
import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor;
import org.thingsboard.server.service.security.auth.extractor.TokenExtractor;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import java.io.IOException;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private final AuthenticationFailureHandler failureHandler;
private final TokenExtractor tokenExtractor;
@ -46,8 +51,7 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token));
}
@ -61,10 +65,27 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
chain.doFilter(request, response);
}
@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (!super.requiresAuthentication(request, response)) {
return false;
}
String header = request.getHeader(AUTHORIZATION_HEADER);
if (header == null) {
header = request.getHeader(AUTHORIZATION_HEADER_V2);
}
if (header == null) {
// If there is NO auth header at all, let the JWT filter try to attempt Authentication and failure in the process.
return true;
}
return header.startsWith(BEARER_HEADER_PREFIX);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
}

40
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java

@ -28,28 +28,29 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken;
import org.thingsboard.server.service.security.auth.TokenOutdatingService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class RefreshTokenAuthenticationProvider implements AuthenticationProvider {
private final JwtTokenFactory tokenFactory;
private final UserService userService;
private final UserAuthDetailsCache userAuthDetailsCache;
private final CustomerService customerService;
private final TokenOutdatingService tokenOutdatingService;
@ -57,7 +58,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.notNull(authentication, "No authentication data provided");
RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();
SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.getToken());
SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.token());
UserPrincipal principal = unsafeUser.getUserPrincipal();
SecurityUser securityUser;
@ -67,7 +68,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
securityUser = authenticateByPublicId(principal.getValue());
}
securityUser.setSessionId(unsafeUser.getSessionId());
if (tokenOutdatingService.isOutdated(rawAccessToken.getToken(), securityUser.getId())) {
if (tokenOutdatingService.isOutdated(rawAccessToken.token(), securityUser.getId())) {
throw new CredentialsExpiredException("Token is outdated");
}
@ -75,27 +76,21 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
}
private SecurityUser authenticateByUserId(UserId userId) {
TenantId systemId = TenantId.SYS_TENANT_ID;
User user = userService.findUserById(systemId, userId);
if (user == null) {
throw new UsernameNotFoundException("User not found by refresh token");
UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(TenantId.SYS_TENANT_ID, userId);
if (userAuthDetails == null) {
throw new UsernameNotFoundException("User with credentials not found");
}
UserCredentials userCredentials = userService.findUserCredentialsByUserId(systemId, user.getId());
if (userCredentials == null) {
throw new UsernameNotFoundException("User credentials not found");
}
if (!userCredentials.isEnabled()) {
if (!userAuthDetails.credentialsEnabled()) {
throw new DisabledException("User is not active");
}
if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned");
User user = userAuthDetails.user();
if (user.getAuthority() == null) {
throw new InsufficientAuthenticationException("User has no authority assigned");
}
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
return securityUser;
return new SecurityUser(user, true, userPrincipal);
}
private SecurityUser authenticateByPublicId(String publicId) {
@ -125,13 +120,12 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId);
SecurityUser securityUser = new SecurityUser(user, true, userPrincipal);
return securityUser;
return new SecurityUser(user, true, userPrincipal);
}
@Override
public boolean supports(Class<?> authentication) {
return (RefreshAuthenticationToken.class.isAssignableFrom(authentication));
}
}

12
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java

@ -51,11 +51,10 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!HttpMethod.POST.name().equals(request.getMethod())) {
if(log.isDebugEnabled()) {
log.debug("Authentication method not supported. Request method: " + request.getMethod());
if (log.isDebugEnabled()) {
log.debug("Authentication method not supported. Request method: {}", request.getMethod());
}
throw new AuthMethodNotSupportedException("Authentication method not supported");
}
@ -67,11 +66,11 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi
throw new AuthenticationServiceException("Invalid refresh token request payload");
}
if (StringUtils.isBlank(refreshTokenRequest.getRefreshToken())) {
if (refreshTokenRequest == null || StringUtils.isBlank(refreshTokenRequest.refreshToken())) {
throw new AuthenticationServiceException("Refresh token is not provided");
}
RawAccessJwtToken token = new RawAccessJwtToken(refreshTokenRequest.getRefreshToken());
RawAccessJwtToken token = new RawAccessJwtToken(refreshTokenRequest.refreshToken());
return this.getAuthenticationManager().authenticate(new RefreshAuthenticationToken(token));
}
@ -88,4 +87,5 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi
SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
}

6
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java

@ -18,15 +18,11 @@ package org.thingsboard.server.service.security.auth.jwt;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class RefreshTokenRequest {
private String refreshToken;
public record RefreshTokenRequest(String refreshToken) {
@JsonCreator
public RefreshTokenRequest(@JsonProperty("refreshToken") String refreshToken) {
this.refreshToken = refreshToken;
}
public String getRefreshToken() {
return refreshToken;
}
}

10
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java

@ -25,12 +25,13 @@ import java.util.List;
import java.util.stream.Collectors;
public class SkipPathRequestMatcher implements RequestMatcher {
private OrRequestMatcher matchers;
private RequestMatcher processingMatcher;
private final OrRequestMatcher matchers;
private final RequestMatcher processingMatcher;
public SkipPathRequestMatcher(List<String> pathsToSkip, String processingPath) {
Assert.notNull(pathsToSkip, "List of paths to skip is required.");
List<RequestMatcher> m = pathsToSkip.stream().map(path -> new AntPathRequestMatcher(path)).collect(Collectors.toList());
List<RequestMatcher> m = pathsToSkip.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList());
matchers = new OrRequestMatcher(m);
processingMatcher = new AntPathRequestMatcher(processingPath);
}
@ -40,6 +41,7 @@ public class SkipPathRequestMatcher implements RequestMatcher {
if (matchers.matches(request)) {
return false;
}
return processingMatcher.matches(request) ? true : false;
return processingMatcher.matches(request);
}
}

2
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java

@ -66,7 +66,7 @@ public class DefaultJwtSettingsValidator implements JwtSettingsValidator {
throw new DataValidationException("JWT token signing key should be a Base64 encoded string representing at least 512 bits of data!");
}
System.arraycopy(decodedKey, 0, RandomUtils.nextBytes(decodedKey.length), 0, decodedKey.length); //secure memory
System.arraycopy(decodedKey, 0, RandomUtils.secure().randomBytes(decodedKey.length), 0, decodedKey.length); // secure memory
}
}

3
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java

@ -22,9 +22,8 @@ import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.security.model.JwtSettings;
/**
* During Install or upgrade the validation is suppressed to keep existing data
* During Install or upgrade, the validation is suppressed to keep existing data
* */
@Primary
@Profile("install")
@Component

1
application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java

@ -20,4 +20,5 @@ import org.thingsboard.server.common.data.security.model.JwtSettings;
public interface JwtSettingsValidator {
void validate(JwtSettings jwtSettings);
}

86
application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java

@ -0,0 +1,86 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.pat;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.RawApiKey;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
@Component
@RequiredArgsConstructor
public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider {
private final ApiKeyService apiKeyService;
private final UserAuthDetailsCache userAuthDetailsCache;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
RawApiKey rawApiKey = (RawApiKey) authentication.getCredentials();
SecurityUser securityUser = authenticate(rawApiKey.apiKey());
return new ApiKeyAuthenticationToken(securityUser);
}
@Override
public boolean supports(Class<?> authentication) {
return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
}
private SecurityUser authenticate(String key) {
if (StringUtils.isEmpty(key)) {
throw new BadCredentialsException("Empty API key");
}
ApiKey apiKey = apiKeyService.findApiKeyByValue(key);
if (apiKey == null) {
throw new BadCredentialsException("User not found for the provided API key");
}
if (!apiKey.isEnabled()) {
throw new DisabledException("API key auth is not active");
}
if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) {
throw new CredentialsExpiredException("API key is expired");
}
UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(apiKey.getTenantId(), apiKey.getUserId());
if (userAuthDetails == null) {
throw new UsernameNotFoundException("User with credentials not found");
}
if (!userAuthDetails.credentialsEnabled()) {
throw new DisabledException("User is not active");
}
User user = userAuthDetails.user();
if (user.getAuthority() == null) {
throw new InsufficientAuthenticationException("User has no authority assigned");
}
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
return new SecurityUser(user, true, userPrincipal);
}
}

61
application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java

@ -0,0 +1,61 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.pat;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.RawApiKey;
import java.io.Serial;
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 2978710889397403536L;
private RawApiKey rawApiKey;
private SecurityUser securityUser;
public ApiKeyAuthenticationToken(RawApiKey rawApiKey) {
super(null);
this.rawApiKey = rawApiKey;
setAuthenticated(false);
}
public ApiKeyAuthenticationToken(SecurityUser securityUser) {
super(securityUser.getAuthorities());
this.eraseCredentials();
this.securityUser = securityUser;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return rawApiKey;
}
@Override
public Object getPrincipal() {
return this.securityUser;
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.rawApiKey = null;
}
}

87
application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java

@ -0,0 +1,87 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.pat;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.thingsboard.server.service.security.auth.extractor.TokenExtractor;
import org.thingsboard.server.service.security.model.token.RawApiKey;
import java.io.IOException;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2;
public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private final AuthenticationFailureHandler failureHandler;
private final TokenExtractor tokenExtractor;
@Autowired
public ApiKeyTokenAuthenticationProcessingFilter(AuthenticationFailureHandler failureHandler,
@Qualifier("apiKeyHeaderTokenExtractor") TokenExtractor tokenExtractor, RequestMatcher matcher) {
super(matcher);
this.failureHandler = failureHandler;
this.tokenExtractor = tokenExtractor;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
RawApiKey rawApiKey = new RawApiKey(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(rawApiKey));
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
chain.doFilter(request, response);
}
@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (!super.requiresAuthentication(request, response)) {
return false;
}
String header = request.getHeader(AUTHORIZATION_HEADER);
if (header == null) {
header = request.getHeader(AUTHORIZATION_HEADER_V2);
}
return header != null && header.startsWith(API_KEY_HEADER_PREFIX);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
}

7
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.service.security.auth.rest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
@ -24,8 +23,6 @@ import org.springframework.security.web.authentication.AuthenticationFailureHand
import org.springframework.stereotype.Component;
import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
import java.io.IOException;
@Component(value = "defaultAuthenticationFailureHandler")
public class RestAwareAuthenticationFailureHandler implements AuthenticationFailureHandler {
@ -37,8 +34,8 @@ public class RestAwareAuthenticationFailureHandler implements AuthenticationFail
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
errorResponseHandler.handle(e, response);
}
}

3
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java

@ -73,7 +73,7 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
.flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30));
tokenPair.setToken(tokenFactory.createMfaToken(securityUser, scope, preVerificationTokenLifetime).getToken());
tokenPair.setToken(tokenFactory.createMfaToken(securityUser, scope, preVerificationTokenLifetime).token());
tokenPair.setRefreshToken(null);
tokenPair.setScope(scope);
return tokenPair;
@ -93,4 +93,5 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}

5
application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java

@ -17,7 +17,11 @@ package org.thingsboard.server.service.security.exception;
import org.springframework.security.core.AuthenticationException;
import java.io.Serial;
public class JwtExpiredTokenException extends AuthenticationException {
@Serial
private static final long serialVersionUID = -5959543783324224864L;
private String token;
@ -34,4 +38,5 @@ public class JwtExpiredTokenException extends AuthenticationException {
public String token() {
return this.token;
}
}

11
application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java

@ -17,15 +17,6 @@ package org.thingsboard.server.service.security.model.token;
import org.thingsboard.server.common.data.security.model.JwtToken;
public final class AccessJwtToken implements JwtToken {
private final String rawToken;
public AccessJwtToken(String rawToken) {
this.rawToken = rawToken;
}
public String getToken() {
return this.rawToken;
}
public record AccessJwtToken(String token) implements JwtToken {
}

2
application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java

@ -235,7 +235,7 @@ public class JwtTokenFactory {
securityUser.setSessionId(UUID.randomUUID().toString());
JwtToken accessToken = createAccessJwtToken(securityUser);
JwtToken refreshToken = createRefreshToken(securityUser);
return new JwtPair(accessToken.getToken(), refreshToken.getToken());
return new JwtPair(accessToken.token(), refreshToken.token());
}
private SecretKey getSecretKey(boolean forceReload) {

5
application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java

@ -20,9 +20,9 @@ import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
@ -43,8 +43,7 @@ public class OAuth2AppTokenFactory {
Jws<Claims> jwsClaims;
try {
jwsClaims = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(appSecret))).build().parseSignedClaims(appToken);
}
catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {
} catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {
throw new IllegalArgumentException("Invalid Application token: ", ex);
} catch (ExpiredJwtException expiredEx) {
throw new IllegalArgumentException("Application token expired", expiredEx);

14
application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java

@ -19,18 +19,6 @@ import org.thingsboard.server.common.data.security.model.JwtToken;
import java.io.Serializable;
public class RawAccessJwtToken implements JwtToken, Serializable {
public record RawAccessJwtToken(String token) implements JwtToken, Serializable {
private static final long serialVersionUID = -797397445703066079L;
private String token;
public RawAccessJwtToken(String token) {
this.token = token;
}
@Override
public String getToken() {
return token;
}
}

18
application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.model.token;
public record RawApiKey(String apiKey) {}

18
application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java

@ -21,10 +21,12 @@ import org.thingsboard.server.common.data.HasCustomerId;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -48,6 +50,7 @@ public class CustomerUserPermissions extends AbstractPermissions {
put(Resource.ASSET_PROFILE, profilePermissionChecker);
put(Resource.TB_RESOURCE, customerResourcePermissionChecker);
put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ));
put(Resource.API_KEY, apiKeysPermissionChecker);
}
private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() {
@ -202,4 +205,19 @@ public class CustomerUserPermissions extends AbstractPermissions {
return user.getTenantId().equals(entity.getTenantId());
}
};
private static final PermissionChecker apiKeysPermissionChecker = new PermissionChecker<ApiKeyId, ApiKeyInfo>() {
@Override
public boolean hasPermission(SecurityUser user, Operation operation) {
return true;
}
@Override
@SuppressWarnings("unchecked")
public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) {
return user.getTenantId().equals(entity.getTenantId());
}
};
}

2
application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java

@ -70,7 +70,7 @@ public class DefaultAccessControlService implements AccessControlService {
permissionDenied();
}
Optional<PermissionChecker> permissionChecker = permissions.getPermissionChecker(resource);
if (!permissionChecker.isPresent()) {
if (permissionChecker.isEmpty()) {
permissionDenied();
}
return permissionChecker.get();

3
application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java

@ -53,7 +53,8 @@ public enum Resource {
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
MOBILE_APP_SETTINGS,
JOB(EntityType.JOB),
AI_MODEL(EntityType.AI_MODEL);
AI_MODEL(EntityType.AI_MODEL),
API_KEY(EntityType.API_KEY);
private final Set<EntityType> entityTypes;

12
application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java

@ -18,8 +18,10 @@ package org.thingsboard.server.service.security.permission;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -45,6 +47,7 @@ public class SysAdminPermissions extends AbstractPermissions {
put(Resource.QUEUE, systemEntityPermissionChecker);
put(Resource.NOTIFICATION, systemEntityPermissionChecker);
put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker);
put(Resource.API_KEY, PermissionChecker.allowAllPermissionChecker);
}
private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() {
@ -71,4 +74,13 @@ public class SysAdminPermissions extends AbstractPermissions {
};
private static final PermissionChecker<ApiKeyId, ApiKeyInfo> apiKeysPermissionChecker = new PermissionChecker<>() {
@Override
public boolean hasPermission(SecurityUser user, Operation operation) {
return true;
}
};
}

17
application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java

@ -20,8 +20,10 @@ import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -59,6 +61,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.JOB, tenantEntityPermissionChecker);
put(Resource.AI_MODEL, aiModelPermissionChecker);
put(Resource.API_KEY, apiKeysPermissionChecker);
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {
@ -163,4 +166,18 @@ public class TenantAdminPermissions extends AbstractPermissions {
};
private static final PermissionChecker<ApiKeyId, ApiKeyInfo> apiKeysPermissionChecker = new PermissionChecker<>() {
@Override
public boolean hasPermission(SecurityUser user, Operation operation) {
return true;
}
@Override
public boolean hasPermission(SecurityUser user, Operation operation, ApiKeyId entityId, ApiKeyInfo entity) {
return user.getTenantId().equals(entity.getTenantId());
}
};
}

56
application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java

@ -0,0 +1,56 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.ttl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.thingsboard.server.dao.pat.ApiKeyDao;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.util.TbCoreComponent;
@Slf4j
@Service
@TbCoreComponent
@ConditionalOnExpression("${sql.ttl.api_keys.enabled:true} && ${sql.ttl.api_keys.ttl:0} > 0")
public class ApiKeysCleanUpService extends AbstractCleanUpService {
public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION =
"#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.api_keys.checking_interval_ms})}";
private final ApiKeyDao apiKeyDao;
public ApiKeysCleanUpService(PartitionService partitionService, ApiKeyDao apiKeyDao) {
super(partitionService);
this.apiKeyDao = apiKeyDao;
}
@Scheduled(
initialDelayString = RANDOM_DELAY_INTERVAL_MS_EXPRESSION,
fixedDelayString = "${sql.ttl.api_keys.checking_interval_ms:86400000}"
)
public void cleanUp() {
long threshold = System.currentTimeMillis();
if (isSystemTenantPartitionMine()) {
int deleted = apiKeyDao.deleteAllByExpirationTimeBefore(threshold);
if (deleted > 0) {
log.info("API key cleanup removed {} keys (thresholdTs={})", deleted, threshold);
}
}
}
}

78
application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java

@ -0,0 +1,78 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.user.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@TbCoreComponent
@RequiredArgsConstructor
public class DefaultUserAuthDetailsCache implements UserAuthDetailsCache {
private final UserService userService;
@Value("${cache.userAuthDetails.maxSize:1000}")
private int cacheMaxSize;
@Value("${cache.userAuthDetails.timeToLiveInMinutes:30}")
private int cacheValueTtl;
private Cache<UserId, UserAuthDetails> cache;
@PostConstruct
private void init() {
cache = Caffeine.newBuilder()
.maximumSize(cacheMaxSize)
.expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES)
.build();
}
@EventListener(ComponentLifecycleMsg.class)
public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
if (event.getEntityId() != null) {
if (event.getEntityId().getEntityType() == EntityType.USER) {
evict(new UserId(event.getEntityId().getId()));
}
}
}
@Override
public UserAuthDetails getUserAuthDetails(TenantId tenantId, UserId userId) {
log.trace("Retrieving user with enabled credentials status for id {} for tenant {} from cache", userId, tenantId);
return cache.get(userId, id -> userService.findUserAuthDetailsByUserId(tenantId, id));
}
public void evict(UserId userId) {
cache.invalidate(userId);
log.trace("Evicted record for user {} from cache", userId);
}
}

26
application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.user.cache;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
public interface UserAuthDetailsCache {
UserAuthDetails getUserAuthDetails(TenantId tenantId, UserId userId);
}

15
application/src/main/resources/thingsboard.yml

@ -151,6 +151,12 @@ security:
tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Base64 encoded
# Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator
user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}"
# API key parameters
api_key:
# Prefix for the auto-generated API key. For example, tb_Ood4dQMxWvMH-76z3E_Cv0mZaBWT0Clk3hRSO0P_jNQ
value_prefix: "${SECURITY_API_KEY_VALUE_PREFIX:tb_}"
# Length of the auto-generated API key. Max is 255
value_bytes_size: "${SECURITY_API_KEY_VALUE_PREFIX:64}"
# Enable/disable case-sensitive username login
user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}"
claim:
@ -430,6 +436,9 @@ sql:
enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records
ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days
checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
api_keys:
enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records
checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
relations:
max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible
pool_size: "${SQL_RELATIONS_POOL_SIZE:4}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls
@ -670,6 +679,9 @@ cache:
aiModel:
timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL
maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled
apiKeys:
timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL
maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled
# Deliberately placed outside the 'specs' group above
notificationRules:
@ -690,6 +702,9 @@ cache:
tbResourceData:
timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL
maxSize: "${CACHE_SPECS_RESOURCE_DATA_MAX_SIZE:100000}" # 0 means the cache is disabled
userAuthDetails:
timeToLiveInMinutes: "${CACHE_SPECS_USER_AUTH_DETAILS_TTL:120}" # User auth details cache TTL
maxSize: "${CACHE_SPECS_USER_AUTH_DETAILS_MAX_SIZE:200000}" # 0 means the cache is disabled
# Spring data parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required.

59
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -200,6 +200,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
@Slf4j
public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
@ -245,6 +247,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected String mobileToken;
protected String username;
protected String apiKey;
protected TenantId tenantId;
protected TenantProfileId tenantProfileId;
protected UserId tenantAdminUserId;
@ -644,13 +648,27 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void setJwtToken(MockHttpServletRequestBuilder request) {
if (this.token != null) {
request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token);
request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, BEARER_HEADER_PREFIX + this.token);
}
if (this.mobileToken != null) {
request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken);
}
}
protected void resetApiKey() {
this.apiKey = null;
}
protected void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
protected void setApiKey(MockHttpServletRequestBuilder request) {
if (this.apiKey != null) {
request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, API_KEY_HEADER_PREFIX + this.apiKey);
}
}
protected DeviceProfile createDeviceProfile(String name) {
return createDeviceProfile(name, null);
}
@ -768,6 +786,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(getRequest);
}
protected ResultActions doGetWithApiKey(String urlTemplate, Object... urlVariables) throws Exception {
MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables);
setApiKey(getRequest);
return mockMvc.perform(getRequest);
}
protected <T> T doGet(String urlTemplate, Class<T> responseClass, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass);
}
@ -791,6 +815,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn()));
}
protected <T> T doGetWithApiKey(String urlTemplate, Class<T> responseClass, Object... urlVariables) throws Exception {
return readResponse(doGetWithApiKey(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass);
}
protected <T> T doGetTyped(String urlTemplate, TypeReference<T> responseType, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType);
}
@ -870,6 +898,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
}
}
protected <T, R> R doPostWithApiKey(String urlTemplate, T content, Class<R> responseClass, String... params) {
try {
return readResponse(doPostWithApiKey(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
protected <T, R> R doPostWithResponse(String urlTemplate, T content, Class<R> responseClass, String... params) throws Exception {
return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
}
@ -937,6 +973,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(postRequest);
}
protected <T> ResultActions doPostWithApiKey(String urlTemplate, T content, String... params) throws Exception {
MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
setApiKey(postRequest);
String json = json(content);
postRequest.contentType(contentType).content(json);
return mockMvc.perform(postRequest);
}
protected <T> ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
setJwtToken(postRequest);
@ -963,6 +1007,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(asyncDispatch(result));
}
protected ResultActions doDeleteWithApiKey(String urlTemplate, String... params) throws Exception {
MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate);
setApiKey(deleteRequest);
populateParams(deleteRequest, params);
return mockMvc.perform(deleteRequest);
}
protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params);
setJwtToken(deleteRequest);
@ -1358,12 +1409,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void postTelemetry(EntityId entityId, String payload) throws Exception {
doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() +
"/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
"/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
}
protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception {
doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() +
"/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
"/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
}
protected CalculatedField saveCalculatedField(CalculatedField calculatedField) {
@ -1372,7 +1423,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected PageData<CalculatedField> getCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception {
return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" +
(type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink);
(type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink);
}
protected PageData<EventInfo> getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception {

144
application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java

@ -0,0 +1,144 @@
/**
* Copyright © 2016-2025 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.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.UUID;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
public class ApiKeyControllerTest extends AbstractControllerTest {
@Before
public void setUp() throws Exception {
loginTenantAdmin();
}
@Test
public void testSaveApiKey() throws Exception {
ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true);
doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
PageData<ApiKeyInfo> pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertEquals(1, pageData.getData().size());
ApiKeyInfo savedApiKey = pageData.getData().get(0);
Assert.assertNotNull(savedApiKey);
Assert.assertEquals(apiKeyInfo.getDescription(), savedApiKey.getDescription());
Assert.assertEquals(apiKeyInfo.isEnabled(), savedApiKey.isEnabled());
Assert.assertEquals(tenantId, savedApiKey.getTenantId());
Assert.assertEquals(tenantAdminUser.getId(), savedApiKey.getUserId());
doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
}
@Test
public void tesFindUserApiKeys() throws Exception {
PageData<ApiKeyInfo> pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertTrue(pageData.getData().isEmpty());
ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true);
int expectedSize = 10;
for (int i = 0; i < expectedSize; i++) {
doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
}
PageData<ApiKeyInfo> pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertEquals(expectedSize, pageData2.getData().size());
pageData2.getData().forEach(apiKey -> {
try {
doDelete("/api/apiKey/" + apiKey.getId()).andExpect(status().isOk());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
@Test
public void testUpdateApiKeyDescription() throws Exception {
ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true);
doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
PageData<ApiKeyInfo> pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertEquals(1, pageData.getData().size());
ApiKeyInfo savedApiKey = pageData.getData().get(0);
String newDescription = "Updated API Key Description";
ApiKeyInfo updatedApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/description", newDescription, ApiKeyInfo.class);
Assert.assertNotNull(updatedApiKeyInfo);
Assert.assertEquals(newDescription, updatedApiKeyInfo.getDescription());
doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
}
@Test
public void testEnableApiKey() throws Exception {
ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true);
doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
PageData<ApiKeyInfo> pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertEquals(1, pageData.getData().size());
ApiKeyInfo savedApiKey = pageData.getData().get(0);
ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class);
Assert.assertNotNull(disabledApiKeyInfo);
Assert.assertFalse(disabledApiKeyInfo.isEnabled());
ApiKeyInfo enabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/true", Boolean.TRUE, ApiKeyInfo.class);
Assert.assertNotNull(enabledApiKeyInfo);
Assert.assertTrue(enabledApiKeyInfo.isEnabled());
doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
}
@Test
public void testDeleteApiKey() throws Exception {
doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound());
ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", false);
doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
PageData<ApiKeyInfo> pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertEquals(1, pageData.getData().size());
ApiKeyInfo savedApiKey = pageData.getData().get(0);
doDelete("/api/apiKey/" + savedApiKey.getId().getId()).andExpect(status().isOk());
}
private ApiKeyInfo constructApiKeyInfo(String description, boolean enabled) {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription(description);
apiKeyInfo.setEnabled(enabled);
apiKeyInfo.setUserId(tenantAdminUserId);
return apiKeyInfo;
}
}

14
application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java

@ -60,7 +60,7 @@ public class JwtTokenFactoryTest {
public void beforeEach() {
jwtSettings = new JwtSettings();
jwtSettings.setTokenIssuer("tb");
jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8)));
jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.secure().nextAlphanumeric(64).getBytes(StandardCharsets.UTF_8)));
jwtSettings.setTokenExpirationTime((int) TimeUnit.HOURS.toSeconds(2));
jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7));
@ -89,7 +89,7 @@ public class JwtTokenFactoryTest {
AccessJwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser);
checkExpirationTime(accessToken, jwtSettings.getTokenExpirationTime());
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.getToken());
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.token());
assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId());
assertThat(parsedSecurityUser.getEmail()).isEqualTo(securityUser.getEmail());
assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> {
@ -112,7 +112,7 @@ public class JwtTokenFactoryTest {
JwtToken refreshToken = tokenFactory.createRefreshToken(securityUser);
checkExpirationTime(refreshToken, jwtSettings.getRefreshTokenExpTime());
SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.getToken());
SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.token());
assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId());
assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> {
return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType())
@ -128,7 +128,7 @@ public class JwtTokenFactoryTest {
JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime);
checkExpirationTime(preVerificationToken, tokenLifetime);
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken());
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.token());
assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId());
assertThat(parsedSecurityUser.getAuthority()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN);
assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId());
@ -144,7 +144,7 @@ public class JwtTokenFactoryTest {
SecurityUser securityUser = createSecurityUser();
String sessionId = securityUser.getSessionId();
String accessToken = tokenFactory.createAccessJwtToken(securityUser).getToken();
String accessToken = tokenFactory.createAccessJwtToken(securityUser).token();
securityUser = tokenFactory.parseAccessJwtToken(accessToken);
assertThat(securityUser.getSessionId()).isNotNull().isEqualTo(sessionId);
@ -158,7 +158,7 @@ public class JwtTokenFactoryTest {
securityUser.setId(new UserId(UUID.randomUUID()));
securityUser.setEmail("tenant@thingsboard.org");
securityUser.setAuthority(Authority.TENANT_ADMIN);
securityUser.setTenantId(new TenantId(UUID.randomUUID()));
securityUser.setTenantId(TenantId.fromUUID(UUID.randomUUID()));
securityUser.setEnabled(true);
securityUser.setFirstName("A");
securityUser.setLastName("B");
@ -179,7 +179,7 @@ public class JwtTokenFactoryTest {
}
private void checkExpirationTime(JwtToken jwtToken, int tokenLifetime) {
Claims claims = tokenFactory.parseTokenClaims(jwtToken.getToken()).getPayload();
Claims claims = tokenFactory.parseTokenClaims(jwtToken.token()).getPayload();
assertThat(claims.getExpiration()).matches(actualExpirationTime -> {
Calendar expirationTime = Calendar.getInstance();
expirationTime.setTime(new Date());

21
application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java

@ -30,15 +30,14 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent;
import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent;
import org.thingsboard.server.common.data.security.model.JwtToken;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider;
import org.thingsboard.server.service.security.exception.JwtExpiredTokenException;
@ -46,6 +45,7 @@ import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
import java.util.UUID;
@ -91,20 +91,16 @@ public class TokenOutdatingTest {
UserId userId = new UserId(UUID.randomUUID());
securityUser = createMockSecurityUser(userId);
UserService userService = mock(UserService.class);
UserAuthDetailsCache userAuthDetailsCache = mock(UserAuthDetailsCache.class);
User user = new User();
user.setId(userId);
user.setAuthority(Authority.TENANT_ADMIN);
user.setEmail("email");
when(userService.findUserById(any(), eq(userId))).thenReturn(user);
UserCredentials userCredentials = new UserCredentials();
userCredentials.setEnabled(true);
when(userService.findUserCredentialsByUserId(any(), eq(userId))).thenReturn(userCredentials);
when(userAuthDetailsCache.getUserAuthDetails(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true));
accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService);
refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userService, mock(CustomerService.class), tokenOutdatingService);
refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userAuthDetailsCache, mock(CustomerService.class), tokenOutdatingService);
}
@Test
@ -114,12 +110,12 @@ public class TokenOutdatingTest {
// Token outdatage time is rounded to 1 sec. Need to wait before outdating so that outdatage time is strictly after token issue time
SECONDS.sleep(1);
eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId()));
assertTrue(tokenOutdatingService.isOutdated(jwtToken.getToken(), securityUser.getId()));
assertTrue(tokenOutdatingService.isOutdated(jwtToken.token(), securityUser.getId()));
SECONDS.sleep(1);
JwtToken newJwtToken = tokenFactory.createAccessJwtToken(securityUser);
assertFalse(tokenOutdatingService.isOutdated(newJwtToken.getToken(), securityUser.getId()));
assertFalse(tokenOutdatingService.isOutdated(newJwtToken.token(), securityUser.getId()));
}
@Test
@ -229,7 +225,7 @@ public class TokenOutdatingTest {
private RawAccessJwtToken getRawJwtToken(JwtToken token) {
return new RawAccessJwtToken(token.getToken());
return new RawAccessJwtToken(token.token());
}
private SecurityUser createMockSecurityUser(UserId userId) {
@ -241,4 +237,5 @@ public class TokenOutdatingTest {
securityUser.setSessionId(UUID.randomUUID().toString());
return securityUser;
}
}

112
application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java

@ -0,0 +1,112 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.pat;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
@DaoSqlTest
public class ApiKeyAuthenticationProviderTest extends AbstractControllerTest {
ApiKey savedApiKey;
@Before
public void setUp() throws Exception {
loginTenantAdmin();
ApiKeyInfo apiKeyInfo = constructApiKeyInfo();
savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
setApiKey(savedApiKey.getValue());
}
@After
public void cleanUp() throws Exception {
resetApiKey();
doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
}
@Test
public void testSaveEdgeWithApiKey() throws Exception {
Edge edge = constructEdge("My edge", "default");
Mockito.reset(tbClusterService, auditLogService);
Edge savedEdge = doPostWithApiKey("/api/edge", edge, Edge.class);
Assert.assertNotNull(savedEdge);
Assert.assertNotNull(savedEdge.getId());
Assert.assertTrue(savedEdge.getCreatedTime() > 0);
Assert.assertEquals(tenantId, savedEdge.getTenantId());
Assert.assertNotNull(savedEdge.getCustomerId());
Assert.assertEquals(NULL_UUID, savedEdge.getCustomerId().getId());
Assert.assertEquals(edge.getName(), savedEdge.getName());
testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(savedEdge, savedEdge.getId(), savedEdge.getId(),
tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.ADDED, 2);
savedEdge.setName("My new edge");
doPostWithApiKey("/api/edge", savedEdge, Edge.class);
Edge foundEdge = doGetWithApiKey("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class);
Assert.assertEquals(foundEdge.getName(), savedEdge.getName());
testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(foundEdge, foundEdge.getId(), foundEdge.getId(),
tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.UPDATED, 1);
doDeleteWithApiKey("/api/edge/" + savedEdge.getId().getId().toString())
.andExpect(status().isOk());
}
@Test
public void testUnauthorizedWhenKeyDisabled() throws Exception {
ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class);
Assert.assertFalse(disabledApiKeyInfo.isEnabled());
doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized());
}
@Test
public void testUnauthorizedWhenKeyExpired() throws Exception {
ApiKeyInfo apiKeyInfo = constructApiKeyInfo();
apiKeyInfo.setExpirationTime(System.currentTimeMillis() - 1000);
ApiKey savedApiKeyWithBad = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
setApiKey(savedApiKeyWithBad.getValue());
doPost("/api/apiKey", savedApiKey, ApiKeyInfo.class);
doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized());
}
private ApiKeyInfo constructApiKeyInfo() {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription("New API key description");
apiKeyInfo.setEnabled(true);
apiKeyInfo.setUserId(tenantAdminUserId);
return apiKeyInfo;
}
}

41
common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java

@ -0,0 +1,41 @@
/**
* Copyright © 2016-2025 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.pat;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.entity.EntityDaoService;
public interface ApiKeyService extends EntityDaoService {
ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKey);
void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force);
void deleteByUserId(TenantId tenantId, UserId userId);
ApiKey findApiKeyByValue(String value);
ApiKey findApiKeyById(TenantId tenantId, ApiKeyId apiKeyId);
PageData<ApiKeyInfo> findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink);
}

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

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.user;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
@ -116,4 +117,7 @@ public interface UserService extends EntityDaoService {
PageData<User> findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink);
boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user);
UserAuthDetails findUserAuthDetailsByUserId(TenantId tenantId, UserId userId);
}

1
common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java

@ -40,6 +40,7 @@ public final class CacheConstants {
public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications";
public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings";
public static final String AI_MODEL_CACHE = "aiModel";
public static final String API_KEYS_CACHE = "apiKeys";
public static final String ASSET_PROFILE_CACHE = "assetProfiles";
public static final String ATTRIBUTES_CACHE = "attributes";

6
common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java

@ -17,6 +17,7 @@ package org.thingsboard.server.common.data;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import java.util.Arrays;
import java.util.EnumSet;
@ -70,14 +71,15 @@ public enum EntityType {
public String getNormalName() {
return "AI model";
}
};
},
API_KEY(44);
@Getter
private final int protoNumber; // Corresponds to EntityTypeProto
@Getter
private final String tableName;
@Getter
private final String normalName = StringUtils.capitalize(StringUtils.removeStart(name(), "TB_")
private final String normalName = StringUtils.capitalize(Strings.CS.removeStart(name(), "TB_")
.toLowerCase().replaceAll("_", " "));
public static final List<String> NORMAL_NAMES = EnumSet.allOf(EntityType.class).stream()

20
common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java

@ -17,12 +17,14 @@ package org.thingsboard.server.common.data;
import com.google.common.base.Splitter;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.Strings;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import static org.apache.commons.lang3.StringUtils.repeat;
@ -131,7 +133,7 @@ public class StringUtils {
}
public static boolean endsWith(String str, String suffix) {
return org.apache.commons.lang3.StringUtils.endsWith(str, suffix);
return Strings.CS.endsWith(str, suffix);
}
public static boolean hasLength(String str) {
@ -147,7 +149,7 @@ public class StringUtils {
}
public static String defaultString(String s, String defaultValue) {
return org.apache.commons.lang3.StringUtils.defaultString(s, defaultValue);
return Objects.toString(s, defaultValue);
}
public static boolean isNumeric(String str) {
@ -155,7 +157,7 @@ public class StringUtils {
}
public static boolean equals(String str1, String str2) {
return org.apache.commons.lang3.StringUtils.equals(str1, str2);
return Strings.CS.equals(str1, str2);
}
public static boolean equalsAny(String string, String... otherStrings) {
@ -199,7 +201,7 @@ public class StringUtils {
}
public static boolean contains(final CharSequence seq, final CharSequence searchSeq) {
return org.apache.commons.lang3.StringUtils.contains(seq, searchSeq);
return Strings.CS.contains(seq, searchSeq);
}
/**
@ -210,23 +212,23 @@ public class StringUtils {
}
public static String randomNumeric(int length) {
return RandomStringUtils.randomNumeric(length);
return RandomStringUtils.secure().nextNumeric(length);
}
public static String random(int length) {
return RandomStringUtils.random(length);
return RandomStringUtils.secure().next(length);
}
public static String random(int length, String chars) {
return RandomStringUtils.random(length, chars);
return RandomStringUtils.secure().next(length, chars);
}
public static String randomAlphanumeric(int count) {
return RandomStringUtils.randomAlphanumeric(count);
return RandomStringUtils.secure().nextAlphanumeric(count);
}
public static String randomAlphabetic(int count) {
return RandomStringUtils.randomAlphabetic(count);
return RandomStringUtils.secure().nextAlphabetic(count);
}
public static String generateSafeToken(int length) {

18
common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2025 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 UserAuthDetails(User user, boolean credentialsEnabled) {}

46
common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java

@ -0,0 +1,46 @@
/**
* Copyright © 2016-2025 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.id;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import org.thingsboard.server.common.data.EntityType;
import java.io.Serial;
import java.util.UUID;
public class ApiKeyId extends UUIDBased implements EntityId {
@Serial
private static final long serialVersionUID = -273913539653684641L;
@JsonCreator
public ApiKeyId(@JsonProperty("id") UUID id) {
super(id);
}
public static ApiKeyId fromString(String secretId) {
return new ApiKeyId(UUID.fromString(secretId));
}
@Override
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_KEY", allowableValues = "API_KEY")
public EntityType getEntityType() {
return EntityType.API_KEY;
}
}

1
common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java

@ -83,6 +83,7 @@ public class EntityIdFactory {
case JOB -> new JobId(uuid);
case ADMIN_SETTINGS -> new AdminSettingsId(uuid);
case AI_MODEL -> new AiModelId(uuid);
case API_KEY -> new ApiKeyId(uuid);
};
}

61
common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java

@ -0,0 +1,61 @@
/**
* Copyright © 2016-2025 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.pat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.validation.NoXss;
import java.io.Serial;
@Schema
@Data
@EqualsAndHashCode(callSuper = true)
public class ApiKey extends ApiKeyInfo {
@Serial
private static final long serialVersionUID = -2313196723950490263L;
@NoXss
@Schema(description = "Api key value", requiredMode = Schema.RequiredMode.REQUIRED)
private String value;
public ApiKey() {
super();
}
public ApiKey(ApiKeyId id) {
super(id);
}
public ApiKey(ApiKey apiKey) {
super(apiKey);
this.value = apiKey.getValue();
}
public ApiKey(ApiKeyInfo apiKeyInfo) {
super(apiKeyInfo);
this.value = null;
}
public ApiKey(ApiKeyInfo apiKeyInfo, String value) {
super(apiKeyInfo);
this.value = value;
}
}

96
common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java

@ -0,0 +1,96 @@
/**
* Copyright © 2016-2025 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.pat;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.BaseData;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoXss;
import java.io.Serial;
@Schema
@Data
@EqualsAndHashCode(callSuper = true)
public class ApiKeyInfo extends BaseData<ApiKeyId> implements HasTenantId {
@Serial
private static final long serialVersionUID = -2313196723950490263L;
@Schema(description = "JSON object with Tenant Id. Tenant Id of the api key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY)
private TenantId tenantId;
@Schema(description = "JSON object with User Id. User Id of the api key cannot be changed.")
private UserId userId;
@Schema(description = "Expiration time of the api key.")
private long expirationTime;
@NoXss
@NotBlank
@Length(fieldName = "description")
@Schema(description = "Api Key description.", example = "Api Key description")
private String description;
@Schema(description = "Enabled/disabled api key.", example = "true")
private boolean enabled;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@Schema(description = "Indicates if the api key is expired based on current time. Returns false if expirationTime is 0 (no expiry).",
example = "false",
accessMode = Schema.AccessMode.READ_ONLY)
public boolean isExpired() {
if (expirationTime == 0) {
return false;
}
return System.currentTimeMillis() > expirationTime;
}
@Schema(description = "JSON object with the Api Key Id. " +
"Specify this field to update the Api Key. " +
"Referencing non-existing Api Key Id will cause error. " +
"Omit this field to create new Api Key.")
@Override
public ApiKeyId getId() {
return super.getId();
}
public ApiKeyInfo() {
super();
}
public ApiKeyInfo(ApiKeyId id) {
super(id);
}
public ApiKeyInfo(ApiKeyInfo apiKeyInfo) {
super(apiKeyInfo);
this.tenantId = apiKeyInfo.getTenantId();
this.userId = apiKeyInfo.getUserId();
this.expirationTime = apiKeyInfo.getExpirationTime();
this.enabled = apiKeyInfo.isEnabled();
this.description = apiKeyInfo.getDescription();
}
}

4
common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java

@ -18,5 +18,7 @@ package org.thingsboard.server.common.data.security.model;
import java.io.Serializable;
public interface JwtToken extends Serializable {
String getToken();
String token();
}

3
common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java

@ -30,9 +30,6 @@ import org.thingsboard.server.common.msg.cluster.ToAllNodesMsg;
import java.io.Serial;
import java.util.Optional;
/**
* @author Andrew Shvayka
*/
@Data
public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg {

1
common/proto/src/main/proto/queue.proto

@ -66,6 +66,7 @@ enum EntityTypeProto {
JOB = 41;
ADMIN_SETTINGS = 42;
AI_MODEL = 43;
API_KEY = 44;
}
enum ApiUsageRecordKeyProto {

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

@ -750,6 +750,17 @@ public class ModelConstants {
public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY;
public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration";
/**
* Api Key constants.
*/
public static final String API_KEY_TABLE_NAME = "api_key";
public static final String API_KEY_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN;
public static final String API_KEY_USER_ID_COLUMN_NAME = USER_ID_PROPERTY;
public static final String API_KEY_VALUE_COLUMN_NAME = "value";
public static final String API_KEY_EXPIRATION_TIME_COLUMN_NAME = "expiration_time";
public static final String API_KEY_ENABLED_COLUMN_NAME = "enabled";
public static final String API_KEY_DESCRIPTION_COLUMN_NAME = "description";
protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN};
protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)};

81
dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2025 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.model.sql;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.model.BaseEntity;
import org.thingsboard.server.dao.model.BaseSqlEntity;
import java.util.UUID;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_DESCRIPTION_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_ENABLED_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_EXPIRATION_TIME_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TENANT_ID_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_USER_ID_COLUMN_NAME;
@Data
@EqualsAndHashCode(callSuper = true)
@MappedSuperclass
public abstract class AbstractApiKeyInfoEntity<T extends ApiKeyInfo> extends BaseSqlEntity<T> implements BaseEntity<T> {
@Column(name = API_KEY_TENANT_ID_COLUMN_NAME)
private UUID tenantId;
@Column(name = API_KEY_USER_ID_COLUMN_NAME)
private UUID userId;
@Column(name = API_KEY_EXPIRATION_TIME_COLUMN_NAME)
private long expirationTime;
@Column(name = API_KEY_ENABLED_COLUMN_NAME)
private boolean enabled;
@Column(name = API_KEY_DESCRIPTION_COLUMN_NAME)
private String description;
public AbstractApiKeyInfoEntity() {
super();
}
public AbstractApiKeyInfoEntity(ApiKeyInfo apiKeyInfo) {
super(apiKeyInfo);
this.tenantId = apiKeyInfo.getTenantId().getId();
this.userId = apiKeyInfo.getUserId().getId();
this.expirationTime = apiKeyInfo.getExpirationTime();
this.description = apiKeyInfo.getDescription();
this.enabled = apiKeyInfo.isEnabled();
}
protected ApiKeyInfo toApiKeyInfo() {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo(new ApiKeyId(getUuid()));
apiKeyInfo.setCreatedTime(createdTime);
apiKeyInfo.setTenantId(TenantId.fromUUID(tenantId));
apiKeyInfo.setUserId(new UserId(userId));
apiKeyInfo.setEnabled(enabled);
apiKeyInfo.setExpirationTime(expirationTime);
apiKeyInfo.setDescription(description);
return apiKeyInfo;
}
}

51
dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java

@ -0,0 +1,51 @@
/**
* Copyright © 2016-2025 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.model.sql;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.pat.ApiKey;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TABLE_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_VALUE_COLUMN_NAME;
@Data
@EqualsAndHashCode(callSuper = true)
@Entity
@Table(name = API_KEY_TABLE_NAME)
public class ApiKeyEntity extends AbstractApiKeyInfoEntity<ApiKey> {
@Column(name = API_KEY_VALUE_COLUMN_NAME)
private String value;
public ApiKeyEntity() {
super();
}
public ApiKeyEntity(ApiKey apiKey) {
super(apiKey);
this.value = apiKey.getValue();
}
@Override
public ApiKey toData() {
return new ApiKey(super.toApiKeyInfo(), value);
}
}

46
dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java

@ -0,0 +1,46 @@
/**
* Copyright © 2016-2025 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.model.sql;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TABLE_NAME;
@Data
@EqualsAndHashCode(callSuper = true)
@Entity
@Table(name = API_KEY_TABLE_NAME)
public class ApiKeyInfoEntity extends AbstractApiKeyInfoEntity<ApiKeyInfo> {
public ApiKeyInfoEntity() {
super();
}
public ApiKeyInfoEntity(ApiKey apiKey) {
super(apiKey);
}
@Override
public ApiKeyInfo toData() {
return super.toApiKeyInfo();
}
}

3
dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java

@ -35,9 +35,6 @@ import org.thingsboard.server.dao.util.mapping.JsonConverter;
import java.util.UUID;
/**
* Created by Valerii Sosliuk on 4/21/2017.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Entity

40
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java

@ -0,0 +1,40 @@
/**
* Copyright © 2016-2025 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.pat;
import org.checkerframework.checker.nullness.qual.NonNull;
import java.io.Serializable;
import static java.util.Objects.requireNonNull;
record ApiKeyCacheKey(String value) implements Serializable {
ApiKeyCacheKey {
requireNonNull(value);
}
static ApiKeyCacheKey of(String value) {
return new ApiKeyCacheKey(value);
}
@NonNull
@Override
public String toString() {
return /* cache name */ "_" + value;
}
}

33
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2025 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.pat;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.thingsboard.server.cache.CaffeineTbTransactionalCache;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.pat.ApiKey;
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
@Service("ApiKeyCache")
public class ApiKeyCaffeineCache extends CaffeineTbTransactionalCache<ApiKeyCacheKey, ApiKey> {
public ApiKeyCaffeineCache(CacheManager cacheManager) {
super(cacheManager, CacheConstants.API_KEYS_CACHE);
}
}

35
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java

@ -0,0 +1,35 @@
/**
* Copyright © 2016-2025 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.pat;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.dao.Dao;
import java.util.Set;
public interface ApiKeyDao extends Dao<ApiKey> {
ApiKey findByValue(String value);
Set<String> deleteByTenantId(TenantId tenantId);
Set<String> deleteByUserId(TenantId tenantId, UserId userId);
int deleteAllByExpirationTimeBefore(long ts);
}

18
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2025 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.pat;
public record ApiKeyEvictEvent(String value) {}

29
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 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.pat;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.Dao;
public interface ApiKeyInfoDao extends Dao<ApiKeyInfo> {
PageData<ApiKeyInfo> findByUserId(TenantId tenantId, UserId userId, PageLink pageLink);
}

36
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2025 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.pat;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.stereotype.Service;
import org.thingsboard.server.cache.CacheSpecsMap;
import org.thingsboard.server.cache.RedisTbTransactionalCache;
import org.thingsboard.server.cache.TBRedisCacheConfiguration;
import org.thingsboard.server.cache.TbJsonRedisSerializer;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.pat.ApiKey;
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis")
@Service("ApiKeyCache")
public class ApiKeyRedisCache extends RedisTbTransactionalCache<ApiKeyCacheKey, ApiKey> {
public ApiKeyRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) {
super(CacheConstants.API_KEYS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(ApiKey.class));
}
}

163
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java

@ -0,0 +1,163 @@
/**
* Copyright © 2016-2025 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.pat;
import com.google.common.util.concurrent.FluentFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.service.validator.ApiKeyDataValidator;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_TENANT_ID;
import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_USER_ID;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyServiceImpl extends AbstractCachedEntityService<ApiKeyCacheKey, ApiKey, ApiKeyEvictEvent> implements ApiKeyService {
private static final String INCORRECT_API_KEY_ID = "Incorrect ApiKeyId ";
private static final int MAX_API_KEY_VALUE_LENGTH = 255;
private final ApiKeyDao apiKeyDao;
private final ApiKeyInfoDao apiKeyInfoDao;
@Lazy
private final ApiKeyDataValidator apiKeyValidator;
@Value("${security.api_key.value_prefix:}")
private String prefix;
@Value("${security.api_key.value_bytes_size:64}")
private int valueBytesSize;
@Override
@TransactionalEventListener
public void handleEvictEvent(ApiKeyEvictEvent event) {
cache.evict(ApiKeyCacheKey.of(event.value()));
}
@Override
public ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKeyInfo) {
log.trace("Executing saveApiKey [{}]", apiKeyInfo);
try {
var apiKey = new ApiKey(apiKeyInfo);
var old = apiKeyValidator.validate(apiKey, ApiKeyInfo::getTenantId);
if (old == null) {
String value = generateApiKeySecret();
apiKey.setValue(value);
} else {
apiKey.setValue(old.getValue());
}
var savedApiKey = apiKeyDao.save(tenantId, apiKey);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entityId(savedApiKey.getId()).entity(savedApiKey).created(apiKey.getId() == null).build());
if (old != null && old.isEnabled() != apiKey.isEnabled()) {
publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue()));
}
return savedApiKey;
} catch (Exception e) {
checkConstraintViolation(e, "api_key_value_unq_key", "API Key with such value already exists!");
throw e;
}
}
@Override
public ApiKey findApiKeyById(TenantId tenantId, ApiKeyId apiKeyId) {
log.trace("Executing findApiKeyById [{}] [{}]", tenantId, apiKeyId);
validateId(apiKeyId, id -> INCORRECT_API_KEY_ID + id);
return apiKeyDao.findById(tenantId, apiKeyId.getId());
}
@Override
public PageData<ApiKeyInfo> findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink) {
log.trace("Executing findApiKeysByUserId [{}][{}]", tenantId, userId);
validateId(userId, id -> INCORRECT_USER_ID + id);
return apiKeyInfoDao.findByUserId(tenantId, userId, pageLink);
}
@Override
public Optional<HasId<?>> findEntity(TenantId tenantId, EntityId entityId) {
return Optional.ofNullable(findApiKeyById(tenantId, new ApiKeyId(entityId.getId())));
}
@Override
public FluentFuture<Optional<HasId<?>>> findEntityAsync(TenantId tenantId, EntityId entityId) {
return FluentFuture.from(apiKeyDao.findByIdAsync(tenantId, entityId.getId()))
.transform(Optional::ofNullable, directExecutor());
}
@Override
public void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force) {
UUID apiKeyId = apiKey.getUuidId();
validateId(apiKeyId, id -> INCORRECT_API_KEY_ID + id);
apiKeyDao.removeById(tenantId, apiKeyId);
publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue()));
}
@Override
public void deleteByTenantId(TenantId tenantId) {
log.trace("Executing deleteApiKeysByTenantId, tenantId [{}]", tenantId);
validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
Set<String> values = apiKeyDao.deleteByTenantId(tenantId);
values.forEach(value -> publishEvictEvent(new ApiKeyEvictEvent(value)));
}
@Override
public void deleteByUserId(TenantId tenantId, UserId userId) {
log.trace("Executing deleteApiKeysByUserId, tenantId [{}]", tenantId);
validateId(userId, id -> INCORRECT_USER_ID + id);
Set<String> values = apiKeyDao.deleteByUserId(tenantId, userId);
values.forEach(value -> publishEvictEvent(new ApiKeyEvictEvent(value)));
}
@Override
public ApiKey findApiKeyByValue(String value) {
log.trace("Executing findApiKeyByValue [{}]", value);
var cacheKey = ApiKeyCacheKey.of(value);
return cache.getAndPutInTransaction(cacheKey, () -> apiKeyDao.findByValue(value), true);
}
private String generateApiKeySecret() {
return prefix + StringUtils.generateSafeToken(Math.min(valueBytesSize, MAX_API_KEY_VALUE_LENGTH));
}
@Override
public EntityType getEntityType() {
return EntityType.API_KEY;
}
}

77
dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java

@ -0,0 +1,77 @@
/**
* Copyright © 2016-2025 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.service.validator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.pat.ApiKeyDao;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.user.UserService;
@Component
@RequiredArgsConstructor
public class ApiKeyDataValidator extends DataValidator<ApiKey> {
private final ApiKeyDao apiKeyDao;
private final TenantService tenantService;
private final UserService userService;
@Override
protected void validateDataImpl(TenantId tenantId, ApiKey apiKey) {
if (apiKey.getId() != null) {
if (apiKey.getUuidId() == null) {
throw new DataValidationException("API Key UUID should be specified!");
}
if (apiKey.getId().isNullUid()) {
throw new DataValidationException("API key UUID must not be the reserved null value!");
}
}
if (apiKey.getTenantId() == null || apiKey.getTenantId().getId() == null) {
throw new DataValidationException("API key should be assigned to tenant!");
}
if (!TenantId.SYS_TENANT_ID.equals(apiKey.getTenantId()) && !tenantService.tenantExists(apiKey.getTenantId())) {
throw new DataValidationException("API key reference a non-existent tenant!");
}
if (apiKey.getUserId() == null || apiKey.getUserId().getId() == null) {
throw new DataValidationException("API key should be assigned to user!");
}
if (userService.findUserById(apiKey.getTenantId(), apiKey.getUserId()) == null) {
throw new DataValidationException("API key reference a non-existent user!");
}
}
@Override
protected ApiKey validateUpdate(TenantId tenantId, ApiKey apiKey) {
ApiKey old = apiKeyDao.findById(tenantId, apiKey.getUuidId());
if (old == null) {
throw new DataValidationException("Cannot update non-existent API key!");
}
if (!old.getUserId().equals(apiKey.getUserId())) {
throw new DataValidationException("Cannot update API key user id!");
}
if (old.getExpirationTime() != apiKey.getExpirationTime()) {
throw new DataValidationException("Cannot update API key expiration time!");
}
return old;
}
}

36
dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2025 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.sql.pat;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.dao.model.sql.ApiKeyInfoEntity;
import java.util.UUID;
public interface ApiKeyInfoRepository extends JpaRepository<ApiKeyInfoEntity, UUID> {
@Query("SELECT ak FROM ApiKeyInfoEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId AND " +
"(:searchText is NULL OR ilike(ak.description, concat('%', :searchText, '%')) = true)")
Page<ApiKeyInfoEntity> findByUserId(@Param("tenantId") UUID tenantId,
@Param("userId") UUID userId,
@Param("searchText") String searchText,
Pageable pageable);
}

58
dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 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.sql.pat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.dao.model.sql.ApiKeyEntity;
import java.util.Set;
import java.util.UUID;
public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {
ApiKeyEntity findByValue(String value);
@Transactional
@Modifying
@Query(value = """
DELETE FROM api_key
WHERE tenant_id = :tenantId
RETURNING value
""", nativeQuery = true
)
Set<String> deleteByTenantId(@Param("tenantId") UUID tenantId);
@Transactional
@Modifying
@Query(value = """
DELETE FROM api_key
WHERE tenant_id = :tenantId AND user_id = :userId
RETURNING value
""", nativeQuery = true
)
Set<String> deleteByUserId(@Param("tenantId") UUID tenantId,
@Param("userId") UUID userId);
@Transactional
@Modifying
@Query("DELETE FROM ApiKeyEntity ak WHERE ak.expirationTime > 0 AND ak.expirationTime < :ts")
int deleteAllByExpirationTimeBefore(@Param("ts") long ts);
}

78
dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java

@ -0,0 +1,78 @@
/**
* Copyright © 2016-2025 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.sql.pat;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.sql.ApiKeyEntity;
import org.thingsboard.server.dao.pat.ApiKeyDao;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.Set;
import java.util.UUID;
@Slf4j
@SqlDao
@Component
public class JpaApiKeyDao extends JpaAbstractDao<ApiKeyEntity, ApiKey> implements ApiKeyDao {
@Autowired
private ApiKeyRepository apiKeyRepository;
@Override
public ApiKey findByValue(String value) {
return DaoUtil.getData(apiKeyRepository.findByValue(value));
}
@Override
public Set<String> deleteByTenantId(TenantId tenantId) {
return apiKeyRepository.deleteByTenantId(tenantId.getId());
}
@Override
public Set<String> deleteByUserId(TenantId tenantId, UserId userId) {
return apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId());
}
@Override
public int deleteAllByExpirationTimeBefore(long ts) {
return apiKeyRepository.deleteAllByExpirationTimeBefore(ts);
}
@Override
protected Class<ApiKeyEntity> getEntityClass() {
return ApiKeyEntity.class;
}
@Override
protected JpaRepository<ApiKeyEntity, UUID> getRepository() {
return apiKeyRepository;
}
@Override
public EntityType getEntityType() {
return EntityType.API_KEY;
}
}

58
dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 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.sql.pat;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.sql.ApiKeyInfoEntity;
import org.thingsboard.server.dao.pat.ApiKeyInfoDao;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.UUID;
@Slf4j
@SqlDao
@Component
public class JpaApiKeyInfoDao extends JpaAbstractDao<ApiKeyInfoEntity, ApiKeyInfo> implements ApiKeyInfoDao {
@Autowired
private ApiKeyInfoRepository apiKeyInfoRepository;
@Override
public PageData<ApiKeyInfo> findByUserId(TenantId tenantId, UserId userId, PageLink pageLink) {
return DaoUtil.toPageData(apiKeyInfoRepository.findByUserId(tenantId.getId(), userId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink)));
}
@Override
protected Class<ApiKeyInfoEntity> getEntityClass() {
return ApiKeyInfoEntity.class;
}
@Override
protected JpaRepository<ApiKeyInfoEntity, UUID> getRepository() {
return apiKeyInfoRepository;
}
}

8
dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java

@ -21,6 +21,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.edqs.fields.UserFields;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.sql.UserEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
@ -136,6 +138,12 @@ public class JpaUserDao extends JpaAbstractDao<UserEntity, User> implements User
DaoUtil.toPageable(pageLink)));
}
@Override
public UserAuthDetails findUserAuthDetailsByUserId(UUID tenantId, UUID userId) {
TbPair<UserEntity, Boolean> result = userRepository.findUserAuthDetailsByUserId(userId);
return new UserAuthDetails(result.getFirst().toData(), result.getSecond());
}
@Override
public int countTenantAdmins(UUID tenantId) {
return userRepository.countByTenantIdAndAuthority(tenantId, Authority.TENANT_ADMIN);

9
dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java

@ -23,15 +23,13 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.edqs.fields.UserFields;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.dao.model.sql.UserEntity;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
/**
* @author Valerii Sosliuk
*/
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
UserEntity findByEmail(String email);
@ -80,4 +78,9 @@ public interface UserRepository extends JpaRepository<UserEntity, UUID> {
List<UserFields> findNextBatch(@Param("id") UUID id, Limit limit);
int countByTenantIdAndAuthority(UUID tenantId, Authority authority);
@Query("SELECT new org.thingsboard.server.common.data.util.TbPair(u, uc.enabled) " +
"FROM UserEntity u JOIN UserCredentialsEntity uc ON u.id = uc.userId WHERE u.id = :userId ")
TbPair<UserEntity, Boolean> findUserAuthDetailsByUserId(@Param("userId") UUID userId);
}

2
dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java

@ -174,7 +174,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService<TenantId, Ten
publishEvictEvent(new TenantEvictEvent(tenantId, true));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(tenantId).entity(tenant).build());
cleanUpService.removeTenantEntities(tenantId, // don't forget to implement deleteEntity from EntityDaoService when adding entity type to this list
cleanUpService.removeTenantEntities(tenantId, // remember to implement deleteEntity from EntityDaoService when adding an entity type to this list
EntityType.ADMIN_SETTINGS, EntityType.JOB, EntityType.ENTITY_VIEW, EntityType.WIDGETS_BUNDLE, EntityType.WIDGET_TYPE,
EntityType.ASSET, EntityType.ASSET_PROFILE, EntityType.DEVICE, EntityType.DEVICE_PROFILE,
EntityType.DASHBOARD, EntityType.EDGE, EntityType.RULE_CHAIN, EntityType.API_USAGE_STATE,

4
dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java

@ -16,6 +16,7 @@
package org.thingsboard.server.dao.user;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
@ -102,4 +103,7 @@ public interface UserDao extends Dao<User>, TenantEntityDao<User> {
PageData<User> findByAuthorityAndTenantProfilesIds(Authority authority, List<TenantProfileId> tenantProfilesIds, PageLink pageLink);
int countTenantAdmins(UUID tenantId);
UserAuthDetails findUserAuthDetailsByUserId(UUID tenantId, UUID userId);
}

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

@ -35,6 +35,7 @@ import org.thingsboard.server.cache.user.UserCacheKey;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
@ -64,6 +65,7 @@ import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
@ -106,6 +108,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
private final UserAuthSettingsDao userAuthSettingsDao;
private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao;
private final ApiKeyService apiKeyService;
private final SecuritySettingsService securitySettingsService;
private final TbTenantProfileCache tenantProfileCache;
private final DataValidator<User> userValidator;
@ -309,7 +312,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
@Override
public UserCredentials checkUserActivationToken(TenantId tenantId, UserCredentials userCredentials) {
if (userCredentials.getActivationTokenTtl() < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration
if (userCredentials.getActivationTokenTtl() < TimeUnit.MINUTES.toMillis(15)) { // renew a link if less than 15 minutes before expiration
userCredentials = generateUserActivationToken(userCredentials);
userCredentials = saveUserCredentials(tenantId, userCredentials);
log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userCredentials.getUserId());
@ -347,6 +350,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
validateId(userId, id -> INCORRECT_USER_ID + id);
userCredentialsDao.removeByUserId(tenantId, userId);
userAuthSettingsDao.removeByUserId(userId);
apiKeyService.deleteByUserId(tenantId, userId);
publishEvictEvent(new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), null));
userSettingsDao.removeByUserId(tenantId, userId);
userDao.removeById(tenantId, userId.getId());
@ -505,6 +509,13 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
return userDao.countTenantAdmins(tenantId.getId());
}
@Override
public UserAuthDetails findUserAuthDetailsByUserId(TenantId tenantId, UserId userId) {
log.trace("Executing findUserAuthDetailsByUserId [{}]", userId);
validateId(userId, id -> INCORRECT_USER_ID + id);
return userDao.findUserAuthDetailsByUserId(tenantId.getId(), userId.getId());
}
private Optional<UserMobileSessionInfo> findMobileSessionInfo(TenantId tenantId, UserId userId) {
return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE))
.map(UserSettings::getSettings).map(settings -> JacksonUtil.treeToValue(settings, UserMobileSessionInfo.class));

6
dao/src/main/resources/sql/schema-entities-idx.sql

@ -20,7 +20,7 @@ CREATE INDEX IF NOT EXISTS idx_alarm_originator_created_time ON alarm(originator
CREATE INDEX IF NOT EXISTS idx_alarm_tenant_created_time ON alarm(tenant_id, created_time DESC);
-- Drop index by 'status' column and replace with new indexes that has only active alarms;
-- Drop index by 'status' column and replace with new indexes that have only active alarms;
CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type_active
ON alarm USING btree (originator_id, type) WHERE cleared = false;
@ -108,8 +108,6 @@ CREATE INDEX IF NOT EXISTS idx_notification_delivery_method_recipient_id_unread
CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag);
CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag);
CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(resource_type, public_resource_key);
CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tenant_id);
@ -117,3 +115,5 @@ CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tena
CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id);
CREATE INDEX IF NOT EXISTS idx_api_key_user_id ON api_key(user_id);

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

@ -709,6 +709,18 @@ CREATE TABLE IF NOT EXISTS api_usage_state (
CONSTRAINT api_usage_state_unq_key UNIQUE (tenant_id, entity_id)
);
CREATE TABLE IF NOT EXISTS api_key (
id uuid NOT NULL CONSTRAINT api_key_pkey PRIMARY KEY,
created_time bigint NOT NULL,
tenant_id uuid,
user_id uuid,
value varchar(512),
enabled boolean NOT NULL DEFAULT TRUE,
expiration_time bigint DEFAULT 0,
description varchar(255),
CONSTRAINT api_key_value_unq_key UNIQUE (value)
);
CREATE TABLE IF NOT EXISTS resource (
id uuid NOT NULL CONSTRAINT resource_pkey PRIMARY KEY,
created_time bigint NOT NULL,

219
dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java

@ -0,0 +1,219 @@
/**
* Copyright © 2016-2025 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.service;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.user.UserService;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DaoSqlTest
public class ApiKeyServiceTest extends AbstractServiceTest {
private static final String TEST_API_KEY_DESCRIPTION = "Test API Key Description";
@Autowired
ApiKeyService apiKeyService;
@Autowired
UserService userService;
private UserId userId;
@Before
public void before() {
User tenantAdmin = new User();
tenantAdmin.setAuthority(Authority.TENANT_ADMIN);
tenantAdmin.setTenantId(tenantId);
tenantAdmin.setEmail("tenant@thingsboard.org");
User user = userService.saveUser(TenantId.SYS_TENANT_ID, tenantAdmin);
userId = user.getId();
}
@After
public void after() {
apiKeyService.deleteByTenantId(tenantId);
User user = userService.findUserById(tenantId, userId);
userService.deleteUser(tenantId, user);
}
@Test
public void testSaveApiKey() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
Assert.assertNotNull(savedApiKey);
Assert.assertNotNull(savedApiKey.getId());
Assert.assertEquals(tenantId, savedApiKey.getTenantId());
Assert.assertEquals(TEST_API_KEY_DESCRIPTION, savedApiKey.getDescription());
Assert.assertTrue(savedApiKey.isEnabled());
Assert.assertNotNull(savedApiKey.getValue());
}
@Test
public void testSaveApiKeyWithTooLongDescription() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(StringUtils.randomAlphabetic(300));
assertThatThrownBy(() -> apiKeyService.saveApiKey(tenantId, apiKeyInfo))
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("description length must be equal or less than 255");
}
@Test
public void testUpdateDescriptionApiKey() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
String newDescription = "Updated API Key Description";
savedApiKey.setDescription(newDescription);
ApiKey updatedApiKey = apiKeyService.saveApiKey(tenantId, savedApiKey);
Assert.assertNotNull(updatedApiKey);
Assert.assertEquals(savedApiKey.getId(), updatedApiKey.getId());
Assert.assertEquals(newDescription, updatedApiKey.getDescription());
Assert.assertEquals(savedApiKey.getValue(), updatedApiKey.getValue());
}
@Test
public void testDisableApiKey() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
savedApiKey.setEnabled(false);
ApiKey disabledApiKey = apiKeyService.saveApiKey(tenantId, savedApiKey);
Assert.assertNotNull(disabledApiKey);
Assert.assertEquals(savedApiKey.getId(), disabledApiKey.getId());
Assert.assertFalse(disabledApiKey.isEnabled());
}
@Test
public void testFindApiKeyById() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
ApiKey foundApiKey = apiKeyService.findApiKeyById(tenantId, savedApiKey.getId());
Assert.assertNotNull(foundApiKey);
Assert.assertEquals(savedApiKey.getId(), foundApiKey.getId());
Assert.assertEquals(savedApiKey.getDescription(), foundApiKey.getDescription());
Assert.assertEquals(savedApiKey.isEnabled(), foundApiKey.isEnabled());
Assert.assertEquals(savedApiKey.getValue(), foundApiKey.getValue());
}
@Test
public void testFindApiKeyByHash() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
ApiKey foundApiKey = apiKeyService.findApiKeyByValue(savedApiKey.getValue());
Assert.assertNotNull(foundApiKey);
Assert.assertEquals(savedApiKey.getId(), foundApiKey.getId());
Assert.assertEquals(savedApiKey.getDescription(), foundApiKey.getDescription());
Assert.assertEquals(savedApiKey.isEnabled(), foundApiKey.isEnabled());
Assert.assertEquals(savedApiKey.getValue(), foundApiKey.getValue());
}
@Test
public void testFindApiKeysByUserId() {
int size = 3;
for (int i = 0; i < size; i++) {
ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i);
apiKeyService.saveApiKey(tenantId, apiKeyInfo);
}
PageLink pageLink = new PageLink(10);
PageData<ApiKeyInfo> pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, pageLink);
Assert.assertNotNull(pageData);
Assert.assertEquals(size, pageData.getData().size());
Assert.assertEquals(size, pageData.getTotalElements());
}
@Test
public void testDeleteApiKey() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
apiKeyService.deleteApiKey(tenantId, savedApiKey, false);
ApiKey foundApiKey = apiKeyService.findApiKeyById(tenantId, savedApiKey.getId());
Assert.assertNull(foundApiKey);
}
@Test
public void testDeleteByTenantId() {
for (int i = 0; i < 3; i++) {
ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i);
apiKeyService.saveApiKey(tenantId, apiKeyInfo);
}
apiKeyService.deleteByTenantId(tenantId);
PageLink pageLink = new PageLink(10);
PageData<ApiKeyInfo> pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, pageLink);
Assert.assertNotNull(pageData);
Assert.assertEquals(0, pageData.getData().size());
Assert.assertEquals(0, pageData.getTotalElements());
}
@Test
public void testDeleteByUserId() {
int size = 3;
for (int i = 0; i < size; i++) {
ApiKeyInfo apiKeyInfo = createApiKeyInfo("API Key " + i);
apiKeyService.saveApiKey(tenantId, apiKeyInfo);
}
PageData<ApiKeyInfo> pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, new PageLink(10));
Assert.assertNotNull(pageData);
Assert.assertEquals(size, pageData.getData().size());
Assert.assertEquals(size, pageData.getTotalElements());
apiKeyService.deleteByUserId(tenantId, userId);
pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, new PageLink(10));
Assert.assertNotNull(pageData);
Assert.assertEquals(0, pageData.getData().size());
Assert.assertEquals(0, pageData.getTotalElements());
}
private ApiKeyInfo createApiKeyInfo(String description) {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setTenantId(tenantId);
apiKeyInfo.setUserId(userId);
apiKeyInfo.setDescription(description);
apiKeyInfo.setEnabled(true);
return apiKeyInfo;
}
}

3
dao/src/test/resources/application-test.properties

@ -114,6 +114,9 @@ cache.specs.trendzSettings.maxSize=10000
cache.specs.aiModel.timeToLiveInMinutes=1440
cache.specs.aiModel.maxSize=10000
cache.specs.apiKeys.timeToLiveInMinutes=1440
cache.specs.apiKeys.maxSize=10000
redis.connection.host=localhost
redis.connection.port=6379
redis.connection.db=0

54
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -19,6 +19,7 @@ import com.auth0.jwt.JWT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.base.Strings;
import lombok.Getter;
import lombok.SneakyThrows;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.concurrent.LazyInitializer;
@ -214,16 +215,15 @@ import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.StringUtils.isEmpty;
/**
* @author Andrew Shvayka
*/
public class RestClient implements Closeable {
private static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization";
private static final String TOKEN_HEADER_PARAM = "X-Authorization";
private static final long AVG_REQUEST_TIMEOUT = TimeUnit.SECONDS.toMillis(30);
protected static final String ACTIVATE_TOKEN_REGEX = "/api/noauth/activate?activateToken=";
private final LazyInitializer<ExecutorService> executor = LazyInitializer.<ExecutorService>builder()
.setInitializer(() -> ThingsBoardExecutors.newWorkStealingPool(10, getClass()))
.get();
@Getter
protected final RestTemplate restTemplate;
protected final RestTemplate loginRestTemplate;
protected final String baseURL;
@ -231,58 +231,68 @@ public class RestClient implements Closeable {
private String username;
private String password;
private String mainToken;
@Getter
private String refreshToken;
private long mainTokenExpTs;
private long refreshTokenExpTs;
private long clientServerTimeDiff;
public enum AuthType {JWT, API_KEY}
public RestClient(String baseURL) {
this(new RestTemplate(), baseURL);
}
public RestClient(RestTemplate restTemplate, String baseURL) {
this(restTemplate, baseURL, null);
this(restTemplate, baseURL, AuthType.JWT, null);
}
public RestClient(RestTemplate restTemplate, String baseURL, String accessToken) {
this(restTemplate, baseURL, AuthType.JWT, accessToken);
}
public RestClient(RestTemplate restTemplate, String baseURL, AuthType authType, String token) {
this.restTemplate = restTemplate;
this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory());
this.baseURL = baseURL;
this.restTemplate.getInterceptors().add((request, bytes, execution) -> {
HttpRequest wrapper = new HttpRequestWrapper(request);
if (accessToken == null) {
long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT;
if (calculatedTs > mainTokenExpTs) {
synchronized (RestClient.this) {
switch (authType) {
case JWT -> {
if (token == null) {
long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT;
if (calculatedTs > mainTokenExpTs) {
if (calculatedTs < refreshTokenExpTs) {
refreshToken();
} else {
doLogin();
synchronized (RestClient.this) {
if (calculatedTs > mainTokenExpTs) {
if (calculatedTs < refreshTokenExpTs) {
refreshToken();
} else {
doLogin();
}
}
}
}
} else {
mainToken = token;
}
wrapper.getHeaders().set(TOKEN_HEADER_PARAM, "Bearer " + mainToken);
}
case API_KEY -> {
wrapper.getHeaders().set(TOKEN_HEADER_PARAM, "ApiKey " + token);
}
} else {
mainToken = accessToken;
}
wrapper.getHeaders().set(JWT_TOKEN_HEADER_PARAM, "Bearer " + mainToken);
return execution.execute(wrapper, bytes);
});
}
public RestTemplate getRestTemplate() {
return restTemplate;
public static RestClient withApiKey(RestTemplate rt, String baseURL, String token) {
return new RestClient(rt, baseURL, AuthType.API_KEY, token);
}
public String getToken() {
return mainToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void refreshToken() {
Map<String, String> refreshTokenRequest = new HashMap<>();
refreshTokenRequest.put("refreshToken", refreshToken);

3
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -76,6 +76,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService;
import org.thingsboard.server.dao.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
@ -375,6 +376,8 @@ public interface TbContext {
JobManager getJobManager();
ApiKeyService getApiKeyService();
boolean isExternalNodeForceAck();
/**

4
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java

@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
@ -174,6 +175,9 @@ public class TenantIdLoader {
case AI_MODEL:
tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null);
break;
case API_KEY:
tenantEntity = ctx.getApiKeyService().findApiKeyById(ctxTenantId, new ApiKeyId(id));
break;
default:
throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType());
}

50
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java

@ -61,6 +61,7 @@ import org.thingsboard.server.common.data.notification.rule.NotificationRule;
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.queue.Queue;
import org.thingsboard.server.common.data.queue.QueueStats;
import org.thingsboard.server.common.data.rpc.Rpc;
@ -86,6 +87,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService;
import org.thingsboard.server.dao.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.resource.ResourceService;
@ -167,6 +169,8 @@ public class TenantIdLoaderTest {
private JobService jobService;
@Mock
private AiModelService aiModelService;
@Mock
private ApiKeyService apiKeyService;
private TenantId tenantId;
private TenantProfileId tenantProfileId;
@ -205,159 +209,119 @@ public class TenantIdLoaderTest {
case CUSTOMER:
Customer customer = new Customer();
customer.setTenantId(tenantId);
when(ctx.getCustomerService()).thenReturn(customerService);
doReturn(customer).when(customerService).findCustomerById(eq(tenantId), any());
break;
case USER:
User user = new User();
user.setTenantId(tenantId);
when(ctx.getUserService()).thenReturn(userService);
doReturn(user).when(userService).findUserById(eq(tenantId), any());
break;
case ASSET:
Asset asset = new Asset();
asset.setTenantId(tenantId);
when(ctx.getAssetService()).thenReturn(assetService);
doReturn(asset).when(assetService).findAssetById(eq(tenantId), any());
break;
case DEVICE:
Device device = new Device();
device.setTenantId(tenantId);
when(ctx.getDeviceService()).thenReturn(deviceService);
doReturn(device).when(deviceService).findDeviceById(eq(tenantId), any());
break;
case ALARM:
Alarm alarm = new Alarm();
alarm.setTenantId(tenantId);
when(ctx.getAlarmService()).thenReturn(alarmService);
doReturn(alarm).when(alarmService).findAlarmById(eq(tenantId), any());
break;
case RULE_CHAIN:
RuleChain ruleChain = new RuleChain();
ruleChain.setTenantId(tenantId);
when(ctx.getRuleChainService()).thenReturn(ruleChainService);
doReturn(ruleChain).when(ruleChainService).findRuleChainById(eq(tenantId), any());
break;
case ENTITY_VIEW:
EntityView entityView = new EntityView();
entityView.setTenantId(tenantId);
when(ctx.getEntityViewService()).thenReturn(entityViewService);
doReturn(entityView).when(entityViewService).findEntityViewById(eq(tenantId), any());
break;
case DASHBOARD:
Dashboard dashboard = new Dashboard();
dashboard.setTenantId(tenantId);
when(ctx.getDashboardService()).thenReturn(dashboardService);
doReturn(dashboard).when(dashboardService).findDashboardById(eq(tenantId), any());
break;
case EDGE:
Edge edge = new Edge();
edge.setTenantId(tenantId);
when(ctx.getEdgeService()).thenReturn(edgeService);
doReturn(edge).when(edgeService).findEdgeById(eq(tenantId), any());
break;
case OTA_PACKAGE:
OtaPackage otaPackage = new OtaPackage();
otaPackage.setTenantId(tenantId);
when(ctx.getOtaPackageService()).thenReturn(otaPackageService);
doReturn(otaPackage).when(otaPackageService).findOtaPackageInfoById(eq(tenantId), any());
break;
case ASSET_PROFILE:
AssetProfile assetProfile = new AssetProfile();
assetProfile.setTenantId(tenantId);
when(ctx.getAssetProfileCache()).thenReturn(assetProfileCache);
doReturn(assetProfile).when(assetProfileCache).get(eq(tenantId), any(AssetProfileId.class));
break;
case DEVICE_PROFILE:
DeviceProfile deviceProfile = new DeviceProfile();
deviceProfile.setTenantId(tenantId);
when(ctx.getDeviceProfileCache()).thenReturn(deviceProfileCache);
doReturn(deviceProfile).when(deviceProfileCache).get(eq(tenantId), any(DeviceProfileId.class));
break;
case WIDGET_TYPE:
WidgetType widgetType = new WidgetType();
widgetType.setTenantId(tenantId);
when(ctx.getWidgetTypeService()).thenReturn(widgetTypeService);
doReturn(widgetType).when(widgetTypeService).findWidgetTypeById(eq(tenantId), any());
break;
case WIDGETS_BUNDLE:
WidgetsBundle widgetsBundle = new WidgetsBundle();
widgetsBundle.setTenantId(tenantId);
when(ctx.getWidgetBundleService()).thenReturn(widgetsBundleService);
doReturn(widgetsBundle).when(widgetsBundleService).findWidgetsBundleById(eq(tenantId), any());
break;
case RPC:
Rpc rpc = new Rpc();
rpc.setTenantId(tenantId);
when(ctx.getRpcService()).thenReturn(rpcService);
doReturn(rpc).when(rpcService).findRpcById(eq(tenantId), any());
break;
case QUEUE:
Queue queue = new Queue();
queue.setTenantId(tenantId);
when(ctx.getQueueService()).thenReturn(queueService);
doReturn(queue).when(queueService).findQueueById(eq(tenantId), any());
break;
case API_USAGE_STATE:
ApiUsageState apiUsageState = new ApiUsageState();
apiUsageState.setTenantId(tenantId);
when(ctx.getRuleEngineApiUsageStateService()).thenReturn(ruleEngineApiUsageStateService);
doReturn(apiUsageState).when(ruleEngineApiUsageStateService).findApiUsageStateById(eq(tenantId), any());
break;
case TB_RESOURCE:
TbResource tbResource = new TbResource();
tbResource.setTenantId(tenantId);
when(ctx.getResourceService()).thenReturn(resourceService);
doReturn(tbResource).when(resourceService).findResourceInfoById(eq(tenantId), any());
break;
case RULE_NODE:
RuleNode ruleNode = new RuleNode();
when(ctx.getRuleChainService()).thenReturn(ruleChainService);
doReturn(ruleNode).when(ruleChainService).findRuleNodeById(eq(tenantId), any());
break;
case TENANT_PROFILE:
TenantProfile tenantProfile = new TenantProfile(tenantProfileId);
when(ctx.getTenantProfile()).thenReturn(tenantProfile);
break;
case NOTIFICATION_TARGET:
NotificationTarget notificationTarget = new NotificationTarget();
@ -431,6 +395,12 @@ public class TenantIdLoaderTest {
when(ctx.getAiModelService()).thenReturn(aiModelService);
doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any());
break;
case API_KEY:
ApiKey apiKey = new ApiKey();
apiKey.setTenantId(tenantId);
when(ctx.getApiKeyService()).thenReturn(apiKeyService);
doReturn(apiKey).when(apiKeyService).findApiKeyById(eq(tenantId), any());
break;
default:
throw new RuntimeException("Unexpected originator EntityType " + entityType);
}

54
ui-ngx/src/app/core/http/api-key.service.ts

@ -0,0 +1,54 @@
///
/// Copyright © 2016-2025 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 { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
import { ApiKeyInfo, ApiKey } from '@shared/models/api-key.models';
@Injectable({
providedIn: 'root'
})
export class ApiKeyService {
constructor(
private http: HttpClient
) {
}
public saveApiKey(apiKey: ApiKeyInfo, config?: RequestConfig): Observable<ApiKey> {
return this.http.post<ApiKey>('/api/apiKey', apiKey, defaultHttpOptionsFromConfig(config));
}
public deleteApiKey(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/apiKey/${id}`, defaultHttpOptionsFromConfig(config));
}
public updateApiKeyDescription(id: string, description: string, config?: RequestConfig): Observable<ApiKeyInfo> {
return this.http.put<ApiKeyInfo>(`/api/apiKey/${id}/description`, description, defaultHttpOptionsFromConfig(config));
}
public enableApiKey(id: string, enabledValue: boolean, config?: RequestConfig): Observable<ApiKeyInfo> {
return this.http.put<ApiKeyInfo>(`/api/apiKey/${id}/enabled/${enabledValue}`, defaultHttpOptionsFromConfig(config));
}
public getUserApiKeys(userId: string, pageLink: PageLink, config?: RequestConfig): Observable<PageData<ApiKeyInfo>> {
return this.http.get<PageData<ApiKeyInfo>>(`/api/apiKeys/${userId}${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
}

83
ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html

@ -0,0 +1,83 @@
<!--
Copyright © 2016-2025 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.
-->
<mat-toolbar class="min-w-0" color="primary">
<h2>{{ 'api-key.generate-title' | translate }}</h2>
<span class="flex-1"></span>
<div tb-help="apiKeys"></div>
<button mat-icon-button
(click)="close()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate"></mat-progress-bar>
}
<div mat-dialog-content>
<section class="tb-form-panel no-border no-padding" [formGroup]="apiKeyForm">
<div class="api-key-text">
<span translate>api-key.generate-text</span>
</div>
<mat-form-field class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>api-key.description</mat-label>
<textarea #input cdkTextareaAutosize matInput formControlName="description" rows="2" maxLength="255"></textarea>
</mat-form-field>
<mat-slide-toggle class="mat-slide" formControlName="enabled">
{{ 'api-key.enable' | translate }}
</mat-slide-toggle>
<section class="flex gap-3">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="flex-1">
<mat-select formControlName="expirationTime" aria-label="Expiration date selector" (selectionChange)="onExpirationDateChange()">
@for (value of expirationTimeOptions; track value) {
<mat-option [value]="value">
{{ value | dateExpiration }}
</mat-option>
}
<mat-option value="never">{{'api-key.expiration-time-never' | translate}}</mat-option>
<mat-option value="custom">{{'api-key.expiration-time-custom' | translate}}</mat-option>
</mat-select>
</mat-form-field>
@if (isCustomExpirationTime()) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>api-key.date</mat-label>
<mat-datetimepicker-toggle [for]="datePicker" matSuffix></mat-datetimepicker-toggle>
<mat-datetimepicker #datePicker type="datetime" openOnFocus="true"></mat-datetimepicker>
<input matInput required formControlName="customExpirationTime"
[matDatetimepicker]="datePicker"
[min]="startDate"/>
</mat-form-field>
}
</section>
</section>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="close()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
(click)="add()"
[disabled]="(isLoading$ | async) || apiKeyForm?.invalid">
{{ 'api-key.generate' | translate }}
</button>
</div>

49
ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2025 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 '../../src/scss/constants';
:host {
display: grid;
width: 700px;
height: 100%;
max-width: 100%;
max-height: 100vh;
grid-template-rows: min-content 4px minmax(auto, 1fr) min-content;
.mat-mdc-dialog-content {
grid-row: 3;
}
.mat-mdc-dialog-actions {
grid-row: 4;
}
.api-key-text {
position: relative;
padding: 8px 16px 8px 16px;
&::before {
content: '';
position: absolute;
inset: 0;
background-color: $tb-primary-color;
border-radius: 6px;
opacity: 0.04;
}
span {
font-size: 12px;
color: rgba(0, 0, 0, 0.54);
}
}
}

103
ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts

@ -0,0 +1,103 @@
///
/// Copyright © 2016-2025 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, Inject } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { FormBuilder, Validators } from '@angular/forms';
import { deepTrim } from '@core/utils';
import { ApiKeyService } from '@core/http/api-key.service';
import { ApiKeyInfo } from '@shared/models/api-key.models';
import { ApiKeysTableDialogData } from '@home/components/api-key/api-keys-table-dialog.component';
import { DAY } from '@shared/models/time/time.models';
@Component({
selector: 'tb-add-api-key-dialog',
templateUrl: './add-api-key-dialog.component.html',
styleUrls: ['./add-api-key-dialog.component.scss']
})
export class AddApiKeyDialogComponent extends DialogComponent<AddApiKeyDialogComponent, ApiKeyInfo | string> {
readonly startDate = new Date();
readonly expirationTimeOptions: Array<number> = [7, 30, 60, 90].map(days => days * DAY);
readonly apiKeyForm = this.fb.group({
description: [{value: null, disabled: false}, [Validators.required]],
enabled: [{value: true, disabled: false}, []],
expirationTime: [{value: this.expirationTimeOptions[1] as string | number, disabled: false}, [Validators.required]],
customExpirationTime: [{value: null, disabled: true}, []],
});
constructor(
protected store: Store<AppState>,
protected router: Router,
public dialogRef: MatDialogRef<AddApiKeyDialogComponent, ApiKeyInfo | string>,
private fb: FormBuilder,
private apiKeyService: ApiKeyService,
@Inject(MAT_DIALOG_DATA) public data: ApiKeysTableDialogData,
) {
super(store, router, dialogRef);
}
close(): void {
this.dialogRef.close(null);
}
add(): void {
const formValue = this.apiKeyForm.value;
const userId = this.data.userId;
const expirationTime = this.calcExpirationTime();
const apiKey = {
...deepTrim(formValue),
expirationTime,
userId,
} as ApiKeyInfo;
this.apiKeyService.saveApiKey(apiKey).subscribe(
(res) => {
this.dialogRef.close(res);
}
);
}
isCustomExpirationTime() {
return this.apiKeyForm.value?.expirationTime === 'custom';
}
onExpirationDateChange() {
const customExpirationTimeControl = this.apiKeyForm.get('customExpirationTime');
if (this.isCustomExpirationTime()) {
customExpirationTimeControl.enable({emitEvent: false});
} else {
customExpirationTimeControl.disable({emitEvent: false});
}
}
private calcExpirationTime(): number {
const expirationTimeValue = this.apiKeyForm.get('expirationTime').value;
let value: number;
if (this.isCustomExpirationTime()) {
value = this.apiKeyForm.get('customExpirationTime').value.getTime();
} else if (expirationTimeValue === 'never') {
value = 0;
} else {
value = expirationTimeValue as number + Date.now();
}
return value;
}
}

101
ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html

@ -0,0 +1,101 @@
<!--
Copyright © 2016-2025 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.
-->
<mat-toolbar class="min-w-0" color="primary">
<h2>{{ 'api-key.generated-api-key-title' | translate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="close()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<div class="tb-form-panel no-padding no-border">
<div class="tb-no-data-text font-normal" translate>api-key.generated-api-key-copy</div>
<div class="tb-form-panel no-padding no-border">
<tb-markdown usePlainMarkdown containerClass="tb-command-code"
[data]='createMarkDownCommand(data.apiKey.value)'></tb-markdown>
</div>
<div class="tb-form-panel no-padding no-border tb-tab-body">
<div class="tb-no-data-text font-normal" translate>api-key.generated-api-key-command</div>
<mat-tab-group [selectedIndex]="selectedTab">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tabs-icon" svgIcon="windows"></mat-icon>
Windows
</ng-template>
<ng-template matTabContent>
<div class="tb-form-panel no-padding no-border tb-tab-body">
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>device.connectivity.install-necessary-client-tools</div>
<div class="tb-install-instruction-text" translate>device.connectivity.install-curl-windows</div>
</div>
<ng-container *ngTemplateOutlet="executeCommand"></ng-container>
</div>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tabs-icon" svgIcon="macos"></mat-icon>
MacOS
</ng-template>
<ng-template matTabContent>
<div class="tb-form-panel no-padding no-border tb-tab-body">
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>device.connectivity.install-necessary-client-tools</div>
<div class="tb-install-instruction-text" translate>device.connectivity.install-curl-macos</div>
</div>
<ng-container *ngTemplateOutlet="executeCommand"></ng-container>
</div>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tabs-icon" svgIcon="linux"></mat-icon>
Linux
</ng-template>
<ng-template matTabContent>
<div class="tb-form-panel no-padding no-border tb-tab-body">
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>device.connectivity.install-necessary-client-tools</div>
<tb-markdown usePlainMarkdown containerClass="tb-command-code"
[data]='createMarkDownCommand("sudo apt-get install curl")'></tb-markdown>
</div>
<ng-container *ngTemplateOutlet="executeCommand"></ng-container>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
</div>
</div>
</div>
<div mat-dialog-actions class="justify-end">
<button mat-raised-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="close()">
{{ 'action.close' | translate }}
</button>
</div>
<ng-template #executeCommand>
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>device.connectivity.execute-following-command</div>
<tb-markdown usePlainMarkdown containerClass="tb-command-code" [data]='createMarkDownCommand(apiKeyCommand)'></tb-markdown>
</div>
</ng-template>

95
ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss

@ -0,0 +1,95 @@
/**
* Copyright © 2016-2025 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 '../../src/scss/constants';
:host{
display: grid;
width: 500px;
height: 100%;
max-width: 100%;
max-height: 100vh;
grid-template-rows: min-content minmax(auto, 1fr) min-content;
.tb-install-instruction-text {
min-height: 42px;
}
}
:host ::ng-deep {
.tb-markdown-view {
.tb-command-code {
.code-wrapper {
padding: 0;
pre[class*=language-] {
margin: 0;
background: #F3F6FA;
border-color: $tb-primary-color;
padding-right: 38px;
overflow: hidden;
overflow-x: auto;
padding-bottom: 4px;
min-height: 42px;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
}
}
button.clipboard-btn {
right: -2px;
p {
color: $tb-primary-color;
}
p, div {
background-color: #F3F6FA;
}
div {
img {
display: none;
}
&:after {
content: "";
position: initial;
display: block;
width: 18px;
height: 18px;
background: $tb-primary-color;
mask-image: url(/assets/copy-code-icon.svg);
-webkit-mask-image: url(/assets/copy-code-icon.svg);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
}
}
}
}
}
.mdc-button__label > span {
.mat-icon {
vertical-align: text-bottom;
box-sizing: initial;
}
}
.tabs-icon {
margin-right: 8px;
}
.tb-form-panel.tb-tab-body {
padding: 16px 0 0;
}
}

77
ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts

@ -0,0 +1,77 @@
///
/// Copyright © 2016-2025 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, Inject } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { userInfoCommand, ApiKey } from '@shared/models/api-key.models';
import { getOS } from '@core/utils';
export interface ApiKeyGeneratedDialogData {
apiKey: ApiKey;
}
@Component({
selector: 'tb-api-key-generated-dialog',
templateUrl: './api-key-generated-dialog.component.html',
styleUrls: ['api-key-generated-dialog.component.scss']
})
export class ApiKeyGeneratedDialogComponent extends DialogComponent<ApiKeyGeneratedDialogComponent, void> {
apiKeyCommand = userInfoCommand(this.data.apiKey.value);
selectedTab: number;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<ApiKeyGeneratedDialogComponent, void>,
@Inject(MAT_DIALOG_DATA) public data: ApiKeyGeneratedDialogData) {
super(store, router, dialogRef);
this.selectTabIndexForUserOS();
}
close(): void {
this.dialogRef.close(null);
}
createMarkDownCommand(command: string): string {
return '```bash\n' +
command +
'{:copy-code}\n' +
'```';
}
private selectTabIndexForUserOS() {
const currentOS = getOS();
switch (currentOS) {
case 'linux':
case 'android':
this.selectedTab = 2;
break;
case 'macos':
case 'ios':
this.selectedTab = 1;
break;
case 'windows':
this.selectedTab = 0;
break;
default:
this.selectedTab = 2;
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save