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.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService; 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.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
@ -572,6 +573,10 @@ public class ActorSystemContext {
@Getter @Getter
private JobManager jobManager; private JobManager jobManager;
@Autowired
@Getter
private ApiKeyService apiKeyService;
@Autowired @Autowired
@Getter @Getter
private OwnerService ownerService; 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.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService; 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.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
@ -911,6 +912,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getJobManager(); return mainCtx.getJobManager();
} }
@Override
public ApiKeyService getApiKeyService() {
return mainCtx.getApiKeyService();
}
@Override @Override
public boolean isExternalNodeForceAck() { public boolean isExternalNodeForceAck() {
return mainCtx.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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; 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.exception.ThingsboardErrorResponseHandler;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.AuthExceptionHandler; 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.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter; 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.RefreshTokenAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenProcessingFilter; 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.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.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.RestAuthenticationProvider;
import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilter; import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilter;
import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter; import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter;
import org.thingsboard.server.transport.http.config.PayloadSizeFilter; import org.thingsboard.server.transport.http.config.PayloadSizeFilter;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -71,10 +72,13 @@ import java.util.List;
@TbCoreComponent @TbCoreComponent
public class ThingsboardSecurityConfiguration { public class ThingsboardSecurityConfiguration {
public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; public static final String AUTHORIZATION_HEADER = "X-Authorization";
public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization"; public static final String AUTHORIZATION_HEADER_V2 = "Authorization";
public static final String JWT_TOKEN_QUERY_PARAM = "token"; 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 DEVICE_API_ENTRY_POINT = "/api/v1/**";
public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login"; public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public"; public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
@ -116,6 +120,8 @@ public class ThingsboardSecurityConfiguration {
private JwtAuthenticationProvider jwtAuthenticationProvider; private JwtAuthenticationProvider jwtAuthenticationProvider;
@Autowired @Autowired
private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider; private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider;
@Autowired
private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider;
@Autowired(required = false) @Autowired(required = false)
OAuth2Configuration oauth2Configuration; OAuth2Configuration oauth2Configuration;
@ -124,6 +130,10 @@ public class ThingsboardSecurityConfiguration {
@Qualifier("jwtHeaderTokenExtractor") @Qualifier("jwtHeaderTokenExtractor")
private TokenExtractor jwtHeaderTokenExtractor; private TokenExtractor jwtHeaderTokenExtractor;
@Autowired
@Qualifier("apiKeyHeaderTokenExtractor")
private TokenExtractor apiKeyHeaderTokenExtractor;
@Autowired @Autowired
private AuthenticationManager authenticationManager; private AuthenticationManager authenticationManager;
@ -139,7 +149,7 @@ public class ThingsboardSecurityConfiguration {
} }
@Bean @Bean
protected FilterRegistrationBean<ShallowEtagHeaderFilter> buildEtagFilter() throws Exception { protected FilterRegistrationBean<ShallowEtagHeaderFilter> buildEtagFilter() {
ShallowEtagHeaderFilter etagFilter = new ShallowEtagHeaderFilter(); ShallowEtagHeaderFilter etagFilter = new ShallowEtagHeaderFilter();
etagFilter.setWriteWeakETag(true); etagFilter.setWriteWeakETag(true);
FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean
@ -150,25 +160,22 @@ public class ThingsboardSecurityConfiguration {
} }
@Bean @Bean
protected RestLoginProcessingFilter buildRestLoginProcessingFilter() throws Exception { protected RestLoginProcessingFilter buildRestLoginProcessingFilter() {
RestLoginProcessingFilter filter = new RestLoginProcessingFilter(FORM_BASED_LOGIN_ENTRY_POINT, successHandler, failureHandler); RestLoginProcessingFilter filter = new RestLoginProcessingFilter(FORM_BASED_LOGIN_ENTRY_POINT, successHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager); filter.setAuthenticationManager(this.authenticationManager);
return filter; return filter;
} }
@Bean @Bean
protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() throws Exception { protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() {
RestPublicLoginProcessingFilter filter = new RestPublicLoginProcessingFilter(PUBLIC_LOGIN_ENTRY_POINT, successHandler, failureHandler); RestPublicLoginProcessingFilter filter = new RestPublicLoginProcessingFilter(PUBLIC_LOGIN_ENTRY_POINT, successHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager); filter.setAuthenticationManager(this.authenticationManager);
return filter; return filter;
} }
protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception { @Bean
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS)); protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() {
pathsToSkip.addAll(Arrays.asList(WS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, SkipPathRequestMatcher matcher = buildSkipPathRequestMatcher();
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);
JwtTokenAuthenticationProcessingFilter filter JwtTokenAuthenticationProcessingFilter filter
= new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher); = new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher);
filter.setAuthenticationManager(this.authenticationManager); filter.setAuthenticationManager(this.authenticationManager);
@ -176,7 +183,30 @@ public class ThingsboardSecurityConfiguration {
} }
@Bean @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); RefreshTokenProcessingFilter filter = new RefreshTokenProcessingFilter(TOKEN_REFRESH_ENTRY_POINT, successHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager); filter.setAuthenticationManager(this.authenticationManager);
return filter; return filter;
@ -187,6 +217,7 @@ public class ThingsboardSecurityConfiguration {
return new ProviderManager(List.of( return new ProviderManager(List.of(
restAuthenticationProvider, restAuthenticationProvider,
jwtAuthenticationProvider, jwtAuthenticationProvider,
apiKeyAuthenticationProvider,
refreshTokenAuthenticationProvider refreshTokenAuthenticationProvider
)); ));
} }
@ -233,6 +264,7 @@ public class ThingsboardSecurityConfiguration {
.addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildApiKeyTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(payloadSizeFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(payloadSizeFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitProcessingFilter, 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.AiModelId;
import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId; 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.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CalculatedFieldId; 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.PageLink;
import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.common.data.page.TimePageLink; 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.ComponentDescriptor;
import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.query.EntityDataSortOrder; 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.OAuth2ClientService;
import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService;
import org.thingsboard.server.dao.ota.OtaPackageService; 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.QueueService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.resource.ResourceService;
@ -221,8 +224,6 @@ import static org.thingsboard.server.dao.service.Validator.validateId;
@TbCoreComponent @TbCoreComponent
public abstract class BaseController { 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_ID = "homeDashboardId";
protected static final String HOME_DASHBOARD_HIDE_TOOLBAR = "homeDashboardHideToolbar"; protected static final String HOME_DASHBOARD_HIDE_TOOLBAR = "homeDashboardHideToolbar";
@ -389,6 +390,9 @@ public abstract class BaseController {
@Autowired @Autowired
protected TbAiModelService tbAiModelService; protected TbAiModelService tbAiModelService;
@Autowired
protected ApiKeyService apiKeyService;
@Value("${server.log_controller_error_stack_trace}") @Value("${server.log_controller_error_stack_trace}")
@Getter @Getter
private boolean logControllerErrorStackTrace; private boolean logControllerErrorStackTrace;
@ -648,6 +652,7 @@ public abstract class BaseController {
case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation);
case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation);
case AI_MODEL -> checkAiModelId(new AiModelId(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); default -> (HasId<? extends EntityId>) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation);
}; };
} catch (Exception e) { } 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 { protected <E extends HasId<I> & HasTenantId, I extends EntityId> E checkEntityId(I entityId, ThrowingBiFunction<TenantId, I, E> findingFunction, Operation operation) throws ThingsboardException {
try { try {
validateId((UUIDBased) entityId, "Invalid entity id"); validateId((UUIDBased) entityId, id -> "Invalid entity id");
SecurityUser user = getCurrentUser(); SecurityUser user = getCurrentUser();
E entity = findingFunction.apply(user.getTenantId(), entityId); E entity = findingFunction.apply(user.getTenantId(), entityId);
checkNotNull(entity, entityId.getEntityType().getNormalName() + " with id [" + entityId + "] is not found"); 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); 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) { protected <I extends EntityId> I emptyId(EntityType entityType) {
return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID);
} }
public static Exception toException(Throwable error) { 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) { 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 { private CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
validateId(calculatedFieldId, "Invalid entity id"); validateId(calculatedFieldId, id -> "Invalid entity id");
SecurityUser user = getCurrentUser(); SecurityUser user = getCurrentUser();
CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId); CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId);
checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found"); 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 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 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 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_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 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."; 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 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 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 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 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."; 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.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.exception.ThingsboardException; 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.id.TenantId;
import org.thingsboard.server.common.data.mobile.app.MobileApp; 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.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.common.data.security.model.JwtPair;
import org.thingsboard.server.config.annotations.ApiOperation; 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.dao.mobile.QrCodeSettingService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.mobile.secret.MobileAppSecretService; 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.lang.Nullable;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException; import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;
@ -137,23 +138,22 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
try { try {
response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setContentType(MediaType.APPLICATION_JSON_VALUE);
if (exception instanceof ThingsboardException) { if (exception instanceof ThingsboardException thingsboardException) {
ThingsboardException thingsboardException = (ThingsboardException) exception;
if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) { if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) {
handleSubscriptionException((ThingsboardException) exception, response); handleSubscriptionException(thingsboardException, response);
} else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) { } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) {
handleDatabaseException(thingsboardException.getCause(), response); handleDatabaseException(thingsboardException.getCause(), response);
} else { } else {
handleThingsboardException((ThingsboardException) exception, response); handleThingsboardException(thingsboardException, response);
} }
} else if (exception instanceof TbRateLimitsException) { } else if (exception instanceof TbRateLimitsException rateLimitsException) {
handleRateLimitException(response, (TbRateLimitsException) exception); handleRateLimitException(response, rateLimitsException);
} else if (exception instanceof AccessDeniedException) { } else if (exception instanceof AccessDeniedException) {
handleAccessDeniedException(response); handleAccessDeniedException(response);
} else if (exception instanceof AuthenticationException) { } else if (exception instanceof AuthenticationException authenticationException) {
handleAuthenticationException((AuthenticationException) exception, response); handleAuthenticationException(authenticationException, response);
} else if (exception instanceof MaxPayloadSizeExceededException) { } else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) {
handleMaxPayloadSizeExceededException(response, (MaxPayloadSizeExceededException) exception); handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException);
} else if (exception instanceof DataAccessException e) { } else if (exception instanceof DataAccessException e) {
handleDatabaseException(e, response); handleDatabaseException(e, response);
} else { } 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)); JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof AuthMethodNotSupportedException) { } else if (authenticationException instanceof AuthMethodNotSupportedException) {
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(authenticationException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(authenticationException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED));
} else if (authenticationException instanceof UserPasswordExpiredException) { } else if (authenticationException instanceof UserPasswordExpiredException expiredException) {
UserPasswordExpiredException expiredException = (UserPasswordExpiredException) authenticationException;
String resetToken = expiredException.getResetToken(); String resetToken = expiredException.getResetToken();
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken)); JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken));
} else if (authenticationException instanceof UserPasswordNotValidException) { } else if (authenticationException instanceof UserPasswordNotValidException expiredException) {
UserPasswordNotValidException expiredException = (UserPasswordNotValidException) authenticationException;
JacksonUtil.writeValue(response.getWriter(), ThingsboardCredentialsViolationResponse.of(expiredException.getMessage())); 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 { } else {
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); 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 -> { case ASSET -> {
onAssetUpdate(event.getEntity(), event.getOldEntity()); 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); tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent);
} }
case RULE_CHAIN -> { case RULE_CHAIN -> {
@ -178,7 +178,7 @@ public class EntityStateSourcingListener {
Asset asset = (Asset) event.getEntity(); Asset asset = (Asset) event.getEntity();
tbClusterService.onAssetDeleted(tenantId, asset, null); 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); tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED);
} }
case NOTIFICATION_REQUEST -> { case NOTIFICATION_REQUEST -> {
@ -234,10 +234,12 @@ public class EntityStateSourcingListener {
log.trace("[{}] ActionEntityEvent called: {}", tenantId, event); log.trace("[{}] ActionEntityEvent called: {}", tenantId, event);
switch (event.getActionType()) { switch (event.getActionType()) {
case CREDENTIALS_UPDATED -> { case CREDENTIALS_UPDATED -> {
if (EntityType.DEVICE.equals(event.getEntityId().getEntityType()) && if (event.getEntityId().getEntityType() == EntityType.DEVICE && event.getEntity() instanceof DeviceCredentials deviceCredentials) {
event.getEntity() instanceof DeviceCredentials deviceCredentials) {
tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(tenantId, tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(tenantId,
(DeviceId) event.getEntityId(), deviceCredentials), null); (DeviceId) event.getEntityId(), deviceCredentials), null);
} else if (event.getEntityId().getEntityType() == EntityType.USER) {
tbClusterService.broadcastEntityStateChangeEvent(event.getTenantId(), event.getEntityId(), ComponentLifecycleEvent.UPDATED);
} }
} }
case ASSIGNED_TO_TENANT -> { 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.ASSET_PROFILE,
EntityType.JOB, EntityType.JOB,
EntityType.TB_RESOURCE, EntityType.TB_RESOURCE,
EntityType.CUSTOMER) EntityType.CUSTOMER,
EntityType.USER)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && 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 // No need to push notifications twice
tbRuleEngineServices.removeAll(tbCoreServices); tbRuleEngineServices.removeAll(tbCoreServices);
} }
for (String serviceId : tbRuleEngineServices) { boolean toRuleEngine = entityType != EntityType.USER;
TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId); if (toRuleEngine) {
ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycle(componentLifecycleMsgProto).build(); for (String serviceId : tbRuleEngineServices) {
toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null); TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId);
toRuleEngineNfs.incrementAndGet(); 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 @Override
public boolean isOutdated(String token, UserId userId) { public boolean isOutdated(String token, UserId userId) {
Claims claims = tokenFactory.parseTokenClaims(token).getBody(); Claims claims = tokenFactory.parseTokenClaims(token).getPayload();
long issueTime = claims.getIssuedAt().getTime(); long issueTime = claims.getIssuedAt().getTime();
String sessionId = claims.get("sessionId", String.class); String sessionId = claims.get("sessionId", String.class);
if (isTokenOutdated(issueTime, userId.toString())){ if (isTokenOutdated(issueTime, userId.toString())) {
return true; return true;
} else { } else {
return sessionId != null && isTokenOutdated(issueTime, sessionId); return sessionId != null && isTokenOutdated(issueTime, sessionId);
} }
} }
private Boolean isTokenOutdated(long issueTime, String 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) { private boolean isTokenOutdated(long issueTime, Long outdatageTime) {
return MILLISECONDS.toSeconds(issueTime) < MILLISECONDS.toSeconds(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 * See the License for the specific language governing permissions and
* limitations under the License. * 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 jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.config.ThingsboardSecurityConfiguration; import org.thingsboard.server.config.ThingsboardSecurityConfiguration;
@Component(value="jwtHeaderTokenExtractor") public abstract class AbstractHeaderTokenExtractor implements TokenExtractor {
public class JwtHeaderTokenExtractor implements TokenExtractor {
public static final String HEADER_PREFIX = "Bearer "; private final String headerPrefix;
protected AbstractHeaderTokenExtractor(String headerPrefix) {
this.headerPrefix = headerPrefix;
}
@Override @Override
public String extract(HttpServletRequest request) { 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)) { if (StringUtils.isBlank(header)) {
header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2); header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2);
if (StringUtils.isBlank(header)) { if (StringUtils.isBlank(header)) {
throw new AuthenticationServiceException("Authorization header cannot be blank!"); 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."); 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 * See the License for the specific language governing permissions and
* limitations under the License. * 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 jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationServiceException; 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.common.data.StringUtils;
import org.thingsboard.server.config.ThingsboardSecurityConfiguration; import org.thingsboard.server.config.ThingsboardSecurityConfiguration;
@Component(value="jwtQueryTokenExtractor") @Component(value = "jwtQueryTokenExtractor")
public class JwtQueryTokenExtractor implements TokenExtractor { public class JwtQueryTokenExtractor implements TokenExtractor {
@Override @Override
@ -39,4 +39,5 @@ public class JwtQueryTokenExtractor implements TokenExtractor {
return token; 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 * See the License for the specific language governing permissions and
* limitations under the License. * 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 jakarta.servlet.http.HttpServletRequest;
public interface TokenExtractor { public interface TokenExtractor {
String extract(HttpServletRequest request); 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 @Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException { public Authentication authenticate(Authentication authentication) throws AuthenticationException {
RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();
SecurityUser securityUser = authenticate(rawAccessToken.getToken()); SecurityUser securityUser = authenticate(rawAccessToken.token());
return new JwtAuthenticationToken(securityUser); return new JwtAuthenticationToken(securityUser);
} }
@ -58,4 +58,5 @@ public class JwtAuthenticationProvider implements AuthenticationProvider {
public boolean supports(Class<?> authentication) { public boolean supports(Class<?> authentication) {
return (JwtAuthenticationToken.class.isAssignableFrom(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.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher;
import org.thingsboard.server.service.security.auth.JwtAuthenticationToken; 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 org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import java.io.IOException; 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 { public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private final AuthenticationFailureHandler failureHandler; private final AuthenticationFailureHandler failureHandler;
private final TokenExtractor tokenExtractor; private final TokenExtractor tokenExtractor;
@ -46,8 +51,7 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
} }
@Override @Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
throws AuthenticationException, IOException, ServletException {
RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(request)); RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token)); return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token));
} }
@ -61,10 +65,27 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
chain.doFilter(request, response); 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 @Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException { AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed); 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.springframework.util.Assert;
import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.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.CustomerId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority; 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.customer.CustomerService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken;
import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.auth.TokenOutdatingService;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal; 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.JwtTokenFactory;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
import java.util.UUID; import java.util.UUID;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class RefreshTokenAuthenticationProvider implements AuthenticationProvider { public class RefreshTokenAuthenticationProvider implements AuthenticationProvider {
private final JwtTokenFactory tokenFactory; private final JwtTokenFactory tokenFactory;
private final UserService userService; private final UserAuthDetailsCache userAuthDetailsCache;
private final CustomerService customerService; private final CustomerService customerService;
private final TokenOutdatingService tokenOutdatingService; private final TokenOutdatingService tokenOutdatingService;
@ -57,7 +58,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
public Authentication authenticate(Authentication authentication) throws AuthenticationException { public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.notNull(authentication, "No authentication data provided"); Assert.notNull(authentication, "No authentication data provided");
RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();
SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.getToken()); SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.token());
UserPrincipal principal = unsafeUser.getUserPrincipal(); UserPrincipal principal = unsafeUser.getUserPrincipal();
SecurityUser securityUser; SecurityUser securityUser;
@ -67,7 +68,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
securityUser = authenticateByPublicId(principal.getValue()); securityUser = authenticateByPublicId(principal.getValue());
} }
securityUser.setSessionId(unsafeUser.getSessionId()); securityUser.setSessionId(unsafeUser.getSessionId());
if (tokenOutdatingService.isOutdated(rawAccessToken.getToken(), securityUser.getId())) { if (tokenOutdatingService.isOutdated(rawAccessToken.token(), securityUser.getId())) {
throw new CredentialsExpiredException("Token is outdated"); throw new CredentialsExpiredException("Token is outdated");
} }
@ -75,27 +76,21 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
} }
private SecurityUser authenticateByUserId(UserId userId) { private SecurityUser authenticateByUserId(UserId userId) {
TenantId systemId = TenantId.SYS_TENANT_ID; UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(TenantId.SYS_TENANT_ID, userId);
User user = userService.findUserById(systemId, userId); if (userAuthDetails == null) {
if (user == null) { throw new UsernameNotFoundException("User with credentials not found");
throw new UsernameNotFoundException("User not found by refresh token");
} }
if (!userAuthDetails.credentialsEnabled()) {
UserCredentials userCredentials = userService.findUserCredentialsByUserId(systemId, user.getId());
if (userCredentials == null) {
throw new UsernameNotFoundException("User credentials not found");
}
if (!userCredentials.isEnabled()) {
throw new DisabledException("User is not active"); 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()); UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); return new SecurityUser(user, true, userPrincipal);
return securityUser;
} }
private SecurityUser authenticateByPublicId(String publicId) { private SecurityUser authenticateByPublicId(String publicId) {
@ -125,13 +120,12 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId); UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId);
SecurityUser securityUser = new SecurityUser(user, true, userPrincipal); return new SecurityUser(user, true, userPrincipal);
return securityUser;
} }
@Override @Override
public boolean supports(Class<?> authentication) { public boolean supports(Class<?> authentication) {
return (RefreshAuthenticationToken.class.isAssignableFrom(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 @Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
throws AuthenticationException, IOException, ServletException {
if (!HttpMethod.POST.name().equals(request.getMethod())) { if (!HttpMethod.POST.name().equals(request.getMethod())) {
if(log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("Authentication method not supported. Request method: " + request.getMethod()); log.debug("Authentication method not supported. Request method: {}", request.getMethod());
} }
throw new AuthMethodNotSupportedException("Authentication method not supported"); throw new AuthMethodNotSupportedException("Authentication method not supported");
} }
@ -67,11 +66,11 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi
throw new AuthenticationServiceException("Invalid refresh token request payload"); 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"); 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)); return this.getAuthenticationManager().authenticate(new RefreshAuthenticationToken(token));
} }
@ -88,4 +87,5 @@ public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessi
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed); 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.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
public class RefreshTokenRequest { public record RefreshTokenRequest(String refreshToken) {
private String refreshToken;
@JsonCreator @JsonCreator
public RefreshTokenRequest(@JsonProperty("refreshToken") String refreshToken) { public RefreshTokenRequest(@JsonProperty("refreshToken") String refreshToken) {
this.refreshToken = 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; import java.util.stream.Collectors;
public class SkipPathRequestMatcher implements RequestMatcher { 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) { public SkipPathRequestMatcher(List<String> pathsToSkip, String processingPath) {
Assert.notNull(pathsToSkip, "List of paths to skip is required."); 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); matchers = new OrRequestMatcher(m);
processingMatcher = new AntPathRequestMatcher(processingPath); processingMatcher = new AntPathRequestMatcher(processingPath);
} }
@ -40,6 +41,7 @@ public class SkipPathRequestMatcher implements RequestMatcher {
if (matchers.matches(request)) { if (matchers.matches(request)) {
return false; 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!"); 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; 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 @Primary
@Profile("install") @Profile("install")
@Component @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 { public interface JwtSettingsValidator {
void validate(JwtSettings jwtSettings); 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; package org.thingsboard.server.service.security.auth.rest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired; 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.springframework.stereotype.Component;
import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
import java.io.IOException;
@Component(value = "defaultAuthenticationFailureHandler") @Component(value = "defaultAuthenticationFailureHandler")
public class RestAwareAuthenticationFailureHandler implements AuthenticationFailureHandler { public class RestAwareAuthenticationFailureHandler implements AuthenticationFailureHandler {
@ -37,8 +34,8 @@ public class RestAwareAuthenticationFailureHandler implements AuthenticationFail
} }
@Override @Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
AuthenticationException e) throws IOException, ServletException {
errorResponseHandler.handle(e, response); 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()) .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0)) .filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30)); .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.setRefreshToken(null);
tokenPair.setScope(scope); tokenPair.setScope(scope);
return tokenPair; return tokenPair;
@ -93,4 +93,5 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); 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 org.springframework.security.core.AuthenticationException;
import java.io.Serial;
public class JwtExpiredTokenException extends AuthenticationException { public class JwtExpiredTokenException extends AuthenticationException {
@Serial
private static final long serialVersionUID = -5959543783324224864L; private static final long serialVersionUID = -5959543783324224864L;
private String token; private String token;
@ -34,4 +38,5 @@ public class JwtExpiredTokenException extends AuthenticationException {
public String token() { public String token() {
return this.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; import org.thingsboard.server.common.data.security.model.JwtToken;
public final class AccessJwtToken implements JwtToken { public record AccessJwtToken(String token) implements JwtToken {
private final String rawToken;
public AccessJwtToken(String rawToken) {
this.rawToken = rawToken;
}
public String getToken() {
return this.rawToken;
}
} }

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()); securityUser.setSessionId(UUID.randomUUID().toString());
JwtToken accessToken = createAccessJwtToken(securityUser); JwtToken accessToken = createAccessJwtToken(securityUser);
JwtToken refreshToken = createRefreshToken(securityUser); JwtToken refreshToken = createRefreshToken(securityUser);
return new JwtPair(accessToken.getToken(), refreshToken.getToken()); return new JwtPair(accessToken.token(), refreshToken.token());
} }
private SecretKey getSecretKey(boolean forceReload) { 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.Jws;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
@ -43,8 +43,7 @@ public class OAuth2AppTokenFactory {
Jws<Claims> jwsClaims; Jws<Claims> jwsClaims;
try { try {
jwsClaims = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(appSecret))).build().parseSignedClaims(appToken); 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); throw new IllegalArgumentException("Invalid Application token: ", ex);
} catch (ExpiredJwtException expiredEx) { } catch (ExpiredJwtException expiredEx) {
throw new IllegalArgumentException("Application token expired", 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; 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.HasTenantId;
import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.User; 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.DashboardId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.UserId; 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.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
@ -48,6 +50,7 @@ public class CustomerUserPermissions extends AbstractPermissions {
put(Resource.ASSET_PROFILE, profilePermissionChecker); put(Resource.ASSET_PROFILE, profilePermissionChecker);
put(Resource.TB_RESOURCE, customerResourcePermissionChecker); put(Resource.TB_RESOURCE, customerResourcePermissionChecker);
put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ)); put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ));
put(Resource.API_KEY, apiKeysPermissionChecker);
} }
private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() { private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() {
@ -202,4 +205,19 @@ public class CustomerUserPermissions extends AbstractPermissions {
return user.getTenantId().equals(entity.getTenantId()); 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(); permissionDenied();
} }
Optional<PermissionChecker> permissionChecker = permissions.getPermissionChecker(resource); Optional<PermissionChecker> permissionChecker = permissions.getPermissionChecker(resource);
if (!permissionChecker.isPresent()) { if (permissionChecker.isEmpty()) {
permissionDenied(); permissionDenied();
} }
return permissionChecker.get(); 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), EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
MOBILE_APP_SETTINGS, MOBILE_APP_SETTINGS,
JOB(EntityType.JOB), JOB(EntityType.JOB),
AI_MODEL(EntityType.AI_MODEL); AI_MODEL(EntityType.AI_MODEL),
API_KEY(EntityType.API_KEY);
private final Set<EntityType> entityTypes; 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.springframework.stereotype.Component;
import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.User; 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.EntityId;
import org.thingsboard.server.common.data.id.UserId; 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.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
@ -45,6 +47,7 @@ public class SysAdminPermissions extends AbstractPermissions {
put(Resource.QUEUE, systemEntityPermissionChecker); put(Resource.QUEUE, systemEntityPermissionChecker);
put(Resource.NOTIFICATION, systemEntityPermissionChecker); put(Resource.NOTIFICATION, systemEntityPermissionChecker);
put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker);
put(Resource.API_KEY, PermissionChecker.allowAllPermissionChecker);
} }
private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() { 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.User;
import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId; 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.EntityId;
import org.thingsboard.server.common.data.id.UserId; 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.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser; 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.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.JOB, tenantEntityPermissionChecker); put(Resource.JOB, tenantEntityPermissionChecker);
put(Resource.AI_MODEL, aiModelPermissionChecker); put(Resource.AI_MODEL, aiModelPermissionChecker);
put(Resource.API_KEY, apiKeysPermissionChecker);
} }
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { 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 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 # 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}" 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 # Enable/disable case-sensitive username login
user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}" user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}"
claim: claim:
@ -430,6 +436,9 @@ sql:
enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records 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 ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days
checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day 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: relations:
max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible 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 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: aiModel:
timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL 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 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 # Deliberately placed outside the 'specs' group above
notificationRules: notificationRules:
@ -690,6 +702,9 @@ cache:
tbResourceData: tbResourceData:
timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL 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 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 parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required. 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.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; 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.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 @Slf4j
public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
@ -245,6 +247,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected String mobileToken; protected String mobileToken;
protected String username; protected String username;
protected String apiKey;
protected TenantId tenantId; protected TenantId tenantId;
protected TenantProfileId tenantProfileId; protected TenantProfileId tenantProfileId;
protected UserId tenantAdminUserId; protected UserId tenantAdminUserId;
@ -644,13 +648,27 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void setJwtToken(MockHttpServletRequestBuilder request) { protected void setJwtToken(MockHttpServletRequestBuilder request) {
if (this.token != null) { 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) { if (this.mobileToken != null) {
request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken); 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) { protected DeviceProfile createDeviceProfile(String name) {
return createDeviceProfile(name, null); return createDeviceProfile(name, null);
} }
@ -768,6 +786,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(getRequest); 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 { protected <T> T doGet(String urlTemplate, Class<T> responseClass, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); 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())); 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 { protected <T> T doGetTyped(String urlTemplate, TypeReference<T> responseType, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType); 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 { 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); return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
} }
@ -937,6 +973,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(postRequest); 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 { protected <T> ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
setJwtToken(postRequest); setJwtToken(postRequest);
@ -963,6 +1007,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(asyncDispatch(result)); 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 { protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params); MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params);
setJwtToken(deleteRequest); setJwtToken(deleteRequest);
@ -1358,12 +1409,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void postTelemetry(EntityId entityId, String payload) throws Exception { protected void postTelemetry(EntityId entityId, String payload) throws Exception {
doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + 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 { protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception {
doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + 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) { 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 { protected PageData<CalculatedField> getCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception {
return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + 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 { 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() { public void beforeEach() {
jwtSettings = new JwtSettings(); jwtSettings = new JwtSettings();
jwtSettings.setTokenIssuer("tb"); 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.setTokenExpirationTime((int) TimeUnit.HOURS.toSeconds(2));
jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7)); jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7));
@ -89,7 +89,7 @@ public class JwtTokenFactoryTest {
AccessJwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser); AccessJwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser);
checkExpirationTime(accessToken, jwtSettings.getTokenExpirationTime()); checkExpirationTime(accessToken, jwtSettings.getTokenExpirationTime());
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.getToken()); SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.token());
assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId());
assertThat(parsedSecurityUser.getEmail()).isEqualTo(securityUser.getEmail()); assertThat(parsedSecurityUser.getEmail()).isEqualTo(securityUser.getEmail());
assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> {
@ -112,7 +112,7 @@ public class JwtTokenFactoryTest {
JwtToken refreshToken = tokenFactory.createRefreshToken(securityUser); JwtToken refreshToken = tokenFactory.createRefreshToken(securityUser);
checkExpirationTime(refreshToken, jwtSettings.getRefreshTokenExpTime()); checkExpirationTime(refreshToken, jwtSettings.getRefreshTokenExpTime());
SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.getToken()); SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.token());
assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId());
assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> {
return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType()) return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType())
@ -128,7 +128,7 @@ public class JwtTokenFactoryTest {
JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime); JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime);
checkExpirationTime(preVerificationToken, tokenLifetime); checkExpirationTime(preVerificationToken, tokenLifetime);
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken()); SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.token());
assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId());
assertThat(parsedSecurityUser.getAuthority()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); assertThat(parsedSecurityUser.getAuthority()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN);
assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId()); assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId());
@ -144,7 +144,7 @@ public class JwtTokenFactoryTest {
SecurityUser securityUser = createSecurityUser(); SecurityUser securityUser = createSecurityUser();
String sessionId = securityUser.getSessionId(); String sessionId = securityUser.getSessionId();
String accessToken = tokenFactory.createAccessJwtToken(securityUser).getToken(); String accessToken = tokenFactory.createAccessJwtToken(securityUser).token();
securityUser = tokenFactory.parseAccessJwtToken(accessToken); securityUser = tokenFactory.parseAccessJwtToken(accessToken);
assertThat(securityUser.getSessionId()).isNotNull().isEqualTo(sessionId); assertThat(securityUser.getSessionId()).isNotNull().isEqualTo(sessionId);
@ -158,7 +158,7 @@ public class JwtTokenFactoryTest {
securityUser.setId(new UserId(UUID.randomUUID())); securityUser.setId(new UserId(UUID.randomUUID()));
securityUser.setEmail("tenant@thingsboard.org"); securityUser.setEmail("tenant@thingsboard.org");
securityUser.setAuthority(Authority.TENANT_ADMIN); securityUser.setAuthority(Authority.TENANT_ADMIN);
securityUser.setTenantId(new TenantId(UUID.randomUUID())); securityUser.setTenantId(TenantId.fromUUID(UUID.randomUUID()));
securityUser.setEnabled(true); securityUser.setEnabled(true);
securityUser.setFirstName("A"); securityUser.setFirstName("A");
securityUser.setLastName("B"); securityUser.setLastName("B");
@ -179,7 +179,7 @@ public class JwtTokenFactoryTest {
} }
private void checkExpirationTime(JwtToken jwtToken, int tokenLifetime) { 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 -> { assertThat(claims.getExpiration()).matches(actualExpirationTime -> {
Calendar expirationTime = Calendar.getInstance(); Calendar expirationTime = Calendar.getInstance();
expirationTime.setTime(new Date()); 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.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.junit4.SpringRunner;
import org.thingsboard.server.common.data.User; 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.id.UserId;
import org.thingsboard.server.common.data.security.Authority; 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.UserCredentialsInvalidationEvent;
import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent; import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent;
import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.common.data.security.model.JwtToken;
import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.service.DaoSqlTest; 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.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider;
import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; 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.UserPrincipal;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
import java.util.UUID; import java.util.UUID;
@ -91,20 +91,16 @@ public class TokenOutdatingTest {
UserId userId = new UserId(UUID.randomUUID()); UserId userId = new UserId(UUID.randomUUID());
securityUser = createMockSecurityUser(userId); securityUser = createMockSecurityUser(userId);
UserService userService = mock(UserService.class); UserAuthDetailsCache userAuthDetailsCache = mock(UserAuthDetailsCache.class);
User user = new User(); User user = new User();
user.setId(userId); user.setId(userId);
user.setAuthority(Authority.TENANT_ADMIN); user.setAuthority(Authority.TENANT_ADMIN);
user.setEmail("email"); user.setEmail("email");
when(userService.findUserById(any(), eq(userId))).thenReturn(user); when(userAuthDetailsCache.getUserAuthDetails(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true));
UserCredentials userCredentials = new UserCredentials();
userCredentials.setEnabled(true);
when(userService.findUserCredentialsByUserId(any(), eq(userId))).thenReturn(userCredentials);
accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService); accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService);
refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userService, mock(CustomerService.class), tokenOutdatingService); refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userAuthDetailsCache, mock(CustomerService.class), tokenOutdatingService);
} }
@Test @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 // 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); SECONDS.sleep(1);
eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId())); eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId()));
assertTrue(tokenOutdatingService.isOutdated(jwtToken.getToken(), securityUser.getId())); assertTrue(tokenOutdatingService.isOutdated(jwtToken.token(), securityUser.getId()));
SECONDS.sleep(1); SECONDS.sleep(1);
JwtToken newJwtToken = tokenFactory.createAccessJwtToken(securityUser); JwtToken newJwtToken = tokenFactory.createAccessJwtToken(securityUser);
assertFalse(tokenOutdatingService.isOutdated(newJwtToken.getToken(), securityUser.getId())); assertFalse(tokenOutdatingService.isOutdated(newJwtToken.token(), securityUser.getId()));
} }
@Test @Test
@ -229,7 +225,7 @@ public class TokenOutdatingTest {
private RawAccessJwtToken getRawJwtToken(JwtToken token) { private RawAccessJwtToken getRawJwtToken(JwtToken token) {
return new RawAccessJwtToken(token.getToken()); return new RawAccessJwtToken(token.token());
} }
private SecurityUser createMockSecurityUser(UserId userId) { private SecurityUser createMockSecurityUser(UserId userId) {
@ -241,4 +237,5 @@ public class TokenOutdatingTest {
securityUser.setSessionId(UUID.randomUUID().toString()); securityUser.setSessionId(UUID.randomUUID().toString());
return securityUser; 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 com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.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.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId; 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); PageData<User> findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink);
boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user); 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 SENT_NOTIFICATIONS_CACHE = "sentNotifications";
public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings";
public static final String AI_MODEL_CACHE = "aiModel"; 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 ASSET_PROFILE_CACHE = "assetProfiles";
public static final String ATTRIBUTES_CACHE = "attributes"; 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 lombok.Getter;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import java.util.Arrays; import java.util.Arrays;
import java.util.EnumSet; import java.util.EnumSet;
@ -70,14 +71,15 @@ public enum EntityType {
public String getNormalName() { public String getNormalName() {
return "AI model"; return "AI model";
} }
}; },
API_KEY(44);
@Getter @Getter
private final int protoNumber; // Corresponds to EntityTypeProto private final int protoNumber; // Corresponds to EntityTypeProto
@Getter @Getter
private final String tableName; private final String tableName;
@Getter @Getter
private final String normalName = StringUtils.capitalize(StringUtils.removeStart(name(), "TB_") private final String normalName = StringUtils.capitalize(Strings.CS.removeStart(name(), "TB_")
.toLowerCase().replaceAll("_", " ")); .toLowerCase().replaceAll("_", " "));
public static final List<String> NORMAL_NAMES = EnumSet.allOf(EntityType.class).stream() 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 com.google.common.base.Splitter;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.Strings;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.function.Function; import java.util.function.Function;
import static org.apache.commons.lang3.StringUtils.repeat; import static org.apache.commons.lang3.StringUtils.repeat;
@ -131,7 +133,7 @@ public class StringUtils {
} }
public static boolean endsWith(String str, String suffix) { 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) { public static boolean hasLength(String str) {
@ -147,7 +149,7 @@ public class StringUtils {
} }
public static String defaultString(String s, String defaultValue) { 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) { public static boolean isNumeric(String str) {
@ -155,7 +157,7 @@ public class StringUtils {
} }
public static boolean equals(String str1, String str2) { 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) { 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) { 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) { public static String randomNumeric(int length) {
return RandomStringUtils.randomNumeric(length); return RandomStringUtils.secure().nextNumeric(length);
} }
public static String random(int length) { public static String random(int length) {
return RandomStringUtils.random(length); return RandomStringUtils.secure().next(length);
} }
public static String random(int length, String chars) { 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) { public static String randomAlphanumeric(int count) {
return RandomStringUtils.randomAlphanumeric(count); return RandomStringUtils.secure().nextAlphanumeric(count);
} }
public static String randomAlphabetic(int count) { public static String randomAlphabetic(int count) {
return RandomStringUtils.randomAlphabetic(count); return RandomStringUtils.secure().nextAlphabetic(count);
} }
public static String generateSafeToken(int length) { 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 JOB -> new JobId(uuid);
case ADMIN_SETTINGS -> new AdminSettingsId(uuid); case ADMIN_SETTINGS -> new AdminSettingsId(uuid);
case AI_MODEL -> new AiModelId(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; import java.io.Serializable;
public interface JwtToken extends 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.io.Serial;
import java.util.Optional; import java.util.Optional;
/**
* @author Andrew Shvayka
*/
@Data @Data
public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg {

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

@ -66,6 +66,7 @@ enum EntityTypeProto {
JOB = 41; JOB = 41;
ADMIN_SETTINGS = 42; ADMIN_SETTINGS = 42;
AI_MODEL = 43; AI_MODEL = 43;
API_KEY = 44;
} }
enum ApiUsageRecordKeyProto { 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_NAME_COLUMN_NAME = NAME_PROPERTY;
public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; 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[] 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)}; 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; import java.util.UUID;
/**
* Created by Valerii Sosliuk on 4/21/2017.
*/
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@Entity @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.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.User; 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.edqs.fields.UserFields;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; 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.PageData;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority; 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.DaoUtil;
import org.thingsboard.server.dao.model.sql.UserEntity; import org.thingsboard.server.dao.model.sql.UserEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.sql.JpaAbstractDao;
@ -136,6 +138,12 @@ public class JpaUserDao extends JpaAbstractDao<UserEntity, User> implements User
DaoUtil.toPageable(pageLink))); 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 @Override
public int countTenantAdmins(UUID tenantId) { public int countTenantAdmins(UUID tenantId) {
return userRepository.countByTenantIdAndAuthority(tenantId, Authority.TENANT_ADMIN); 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.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.edqs.fields.UserFields; import org.thingsboard.server.common.data.edqs.fields.UserFields;
import org.thingsboard.server.common.data.security.Authority; 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 org.thingsboard.server.dao.model.sql.UserEntity;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
/**
* @author Valerii Sosliuk
*/
public interface UserRepository extends JpaRepository<UserEntity, UUID> { public interface UserRepository extends JpaRepository<UserEntity, UUID> {
UserEntity findByEmail(String email); UserEntity findByEmail(String email);
@ -80,4 +78,9 @@ public interface UserRepository extends JpaRepository<UserEntity, UUID> {
List<UserFields> findNextBatch(@Param("id") UUID id, Limit limit); List<UserFields> findNextBatch(@Param("id") UUID id, Limit limit);
int countByTenantIdAndAuthority(UUID tenantId, Authority authority); 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)); publishEvictEvent(new TenantEvictEvent(tenantId, true));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(tenantId).entity(tenant).build()); 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.ADMIN_SETTINGS, EntityType.JOB, EntityType.ENTITY_VIEW, EntityType.WIDGETS_BUNDLE, EntityType.WIDGET_TYPE,
EntityType.ASSET, EntityType.ASSET_PROFILE, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET, EntityType.ASSET_PROFILE, EntityType.DEVICE, EntityType.DEVICE_PROFILE,
EntityType.DASHBOARD, EntityType.EDGE, EntityType.RULE_CHAIN, EntityType.API_USAGE_STATE, 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; package org.thingsboard.server.dao.user;
import org.thingsboard.server.common.data.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.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId; 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); PageData<User> findByAuthorityAndTenantProfilesIds(Authority authority, List<TenantProfileId> tenantProfilesIds, PageLink pageLink);
int countTenantAdmins(UUID tenantId); 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.EntityType;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User; 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.audit.ActionType;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId; 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.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.exception.IncorrectParameterException; 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.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.dao.settings.SecuritySettingsService;
@ -106,6 +108,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
private final UserAuthSettingsDao userAuthSettingsDao; private final UserAuthSettingsDao userAuthSettingsDao;
private final UserSettingsService userSettingsService; private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao; private final UserSettingsDao userSettingsDao;
private final ApiKeyService apiKeyService;
private final SecuritySettingsService securitySettingsService; private final SecuritySettingsService securitySettingsService;
private final TbTenantProfileCache tenantProfileCache; private final TbTenantProfileCache tenantProfileCache;
private final DataValidator<User> userValidator; private final DataValidator<User> userValidator;
@ -309,7 +312,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
@Override @Override
public UserCredentials checkUserActivationToken(TenantId tenantId, UserCredentials userCredentials) { 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 = generateUserActivationToken(userCredentials);
userCredentials = saveUserCredentials(tenantId, userCredentials); userCredentials = saveUserCredentials(tenantId, userCredentials);
log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userCredentials.getUserId()); 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); validateId(userId, id -> INCORRECT_USER_ID + id);
userCredentialsDao.removeByUserId(tenantId, userId); userCredentialsDao.removeByUserId(tenantId, userId);
userAuthSettingsDao.removeByUserId(userId); userAuthSettingsDao.removeByUserId(userId);
apiKeyService.deleteByUserId(tenantId, userId);
publishEvictEvent(new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), null)); publishEvictEvent(new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), null));
userSettingsDao.removeByUserId(tenantId, userId); userSettingsDao.removeByUserId(tenantId, userId);
userDao.removeById(tenantId, userId.getId()); userDao.removeById(tenantId, userId.getId());
@ -505,6 +509,13 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
return userDao.countTenantAdmins(tenantId.getId()); 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) { private Optional<UserMobileSessionInfo> findMobileSessionInfo(TenantId tenantId, UserId userId) {
return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE)) return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE))
.map(UserSettings::getSettings).map(settings -> JacksonUtil.treeToValue(settings, UserMobileSessionInfo.class)); .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); 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 CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type_active
ON alarm USING btree (originator_id, type) WHERE cleared = false; 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_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 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); 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_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_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) 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 ( CREATE TABLE IF NOT EXISTS resource (
id uuid NOT NULL CONSTRAINT resource_pkey PRIMARY KEY, id uuid NOT NULL CONSTRAINT resource_pkey PRIMARY KEY,
created_time bigint NOT NULL, 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.timeToLiveInMinutes=1440
cache.specs.aiModel.maxSize=10000 cache.specs.aiModel.maxSize=10000
cache.specs.apiKeys.timeToLiveInMinutes=1440
cache.specs.apiKeys.maxSize=10000
redis.connection.host=localhost redis.connection.host=localhost
redis.connection.port=6379 redis.connection.port=6379
redis.connection.db=0 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.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.concurrent.LazyInitializer; 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; import static org.thingsboard.server.common.data.StringUtils.isEmpty;
/**
* @author Andrew Shvayka
*/
public class RestClient implements Closeable { 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); private static final long AVG_REQUEST_TIMEOUT = TimeUnit.SECONDS.toMillis(30);
protected static final String ACTIVATE_TOKEN_REGEX = "/api/noauth/activate?activateToken="; protected static final String ACTIVATE_TOKEN_REGEX = "/api/noauth/activate?activateToken=";
private final LazyInitializer<ExecutorService> executor = LazyInitializer.<ExecutorService>builder() private final LazyInitializer<ExecutorService> executor = LazyInitializer.<ExecutorService>builder()
.setInitializer(() -> ThingsBoardExecutors.newWorkStealingPool(10, getClass())) .setInitializer(() -> ThingsBoardExecutors.newWorkStealingPool(10, getClass()))
.get(); .get();
@Getter
protected final RestTemplate restTemplate; protected final RestTemplate restTemplate;
protected final RestTemplate loginRestTemplate; protected final RestTemplate loginRestTemplate;
protected final String baseURL; protected final String baseURL;
@ -231,58 +231,68 @@ public class RestClient implements Closeable {
private String username; private String username;
private String password; private String password;
private String mainToken; private String mainToken;
@Getter
private String refreshToken; private String refreshToken;
private long mainTokenExpTs; private long mainTokenExpTs;
private long refreshTokenExpTs; private long refreshTokenExpTs;
private long clientServerTimeDiff; private long clientServerTimeDiff;
public enum AuthType {JWT, API_KEY}
public RestClient(String baseURL) { public RestClient(String baseURL) {
this(new RestTemplate(), baseURL); this(new RestTemplate(), baseURL);
} }
public RestClient(RestTemplate restTemplate, String 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) { 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.restTemplate = restTemplate;
this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory()); this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory());
this.baseURL = baseURL; this.baseURL = baseURL;
this.restTemplate.getInterceptors().add((request, bytes, execution) -> { this.restTemplate.getInterceptors().add((request, bytes, execution) -> {
HttpRequest wrapper = new HttpRequestWrapper(request); HttpRequest wrapper = new HttpRequestWrapper(request);
if (accessToken == null) { switch (authType) {
long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT; case JWT -> {
if (calculatedTs > mainTokenExpTs) { if (token == null) {
synchronized (RestClient.this) { long calculatedTs = System.currentTimeMillis() + clientServerTimeDiff + AVG_REQUEST_TIMEOUT;
if (calculatedTs > mainTokenExpTs) { if (calculatedTs > mainTokenExpTs) {
if (calculatedTs < refreshTokenExpTs) { synchronized (RestClient.this) {
refreshToken(); if (calculatedTs > mainTokenExpTs) {
} else { if (calculatedTs < refreshTokenExpTs) {
doLogin(); 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); return execution.execute(wrapper, bytes);
}); });
} }
public RestTemplate getRestTemplate() { public static RestClient withApiKey(RestTemplate rt, String baseURL, String token) {
return restTemplate; return new RestClient(rt, baseURL, AuthType.API_KEY, token);
} }
public String getToken() { public String getToken() {
return mainToken; return mainToken;
} }
public String getRefreshToken() {
return refreshToken;
}
public void refreshToken() { public void refreshToken() {
Map<String, String> refreshTokenRequest = new HashMap<>(); Map<String, String> refreshTokenRequest = new HashMap<>();
refreshTokenRequest.put("refreshToken", refreshToken); 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.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService; 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.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
@ -375,6 +376,8 @@ public interface TbContext {
JobManager getJobManager(); JobManager getJobManager();
ApiKeyService getApiKeyService();
boolean isExternalNodeForceAck(); 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.HasTenantId;
import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.AlarmId; 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.ApiUsageStateId;
import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.AssetProfileId;
@ -174,6 +175,9 @@ public class TenantIdLoader {
case AI_MODEL: case AI_MODEL:
tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null); tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null);
break; break;
case API_KEY:
tenantEntity = ctx.getApiKeyService().findApiKeyById(ctxTenantId, new ApiKeyId(id));
break;
default: default:
throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); 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.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.oauth2.OAuth2Client; 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.Queue;
import org.thingsboard.server.common.data.queue.QueueStats; import org.thingsboard.server.common.data.queue.QueueStats;
import org.thingsboard.server.common.data.rpc.Rpc; 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.notification.NotificationTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.ota.OtaPackageService; 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.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.resource.ResourceService;
@ -167,6 +169,8 @@ public class TenantIdLoaderTest {
private JobService jobService; private JobService jobService;
@Mock @Mock
private AiModelService aiModelService; private AiModelService aiModelService;
@Mock
private ApiKeyService apiKeyService;
private TenantId tenantId; private TenantId tenantId;
private TenantProfileId tenantProfileId; private TenantProfileId tenantProfileId;
@ -205,159 +209,119 @@ public class TenantIdLoaderTest {
case CUSTOMER: case CUSTOMER:
Customer customer = new Customer(); Customer customer = new Customer();
customer.setTenantId(tenantId); customer.setTenantId(tenantId);
when(ctx.getCustomerService()).thenReturn(customerService); when(ctx.getCustomerService()).thenReturn(customerService);
doReturn(customer).when(customerService).findCustomerById(eq(tenantId), any()); doReturn(customer).when(customerService).findCustomerById(eq(tenantId), any());
break; break;
case USER: case USER:
User user = new User(); User user = new User();
user.setTenantId(tenantId); user.setTenantId(tenantId);
when(ctx.getUserService()).thenReturn(userService); when(ctx.getUserService()).thenReturn(userService);
doReturn(user).when(userService).findUserById(eq(tenantId), any()); doReturn(user).when(userService).findUserById(eq(tenantId), any());
break; break;
case ASSET: case ASSET:
Asset asset = new Asset(); Asset asset = new Asset();
asset.setTenantId(tenantId); asset.setTenantId(tenantId);
when(ctx.getAssetService()).thenReturn(assetService); when(ctx.getAssetService()).thenReturn(assetService);
doReturn(asset).when(assetService).findAssetById(eq(tenantId), any()); doReturn(asset).when(assetService).findAssetById(eq(tenantId), any());
break; break;
case DEVICE: case DEVICE:
Device device = new Device(); Device device = new Device();
device.setTenantId(tenantId); device.setTenantId(tenantId);
when(ctx.getDeviceService()).thenReturn(deviceService); when(ctx.getDeviceService()).thenReturn(deviceService);
doReturn(device).when(deviceService).findDeviceById(eq(tenantId), any()); doReturn(device).when(deviceService).findDeviceById(eq(tenantId), any());
break; break;
case ALARM: case ALARM:
Alarm alarm = new Alarm(); Alarm alarm = new Alarm();
alarm.setTenantId(tenantId); alarm.setTenantId(tenantId);
when(ctx.getAlarmService()).thenReturn(alarmService); when(ctx.getAlarmService()).thenReturn(alarmService);
doReturn(alarm).when(alarmService).findAlarmById(eq(tenantId), any()); doReturn(alarm).when(alarmService).findAlarmById(eq(tenantId), any());
break; break;
case RULE_CHAIN: case RULE_CHAIN:
RuleChain ruleChain = new RuleChain(); RuleChain ruleChain = new RuleChain();
ruleChain.setTenantId(tenantId); ruleChain.setTenantId(tenantId);
when(ctx.getRuleChainService()).thenReturn(ruleChainService); when(ctx.getRuleChainService()).thenReturn(ruleChainService);
doReturn(ruleChain).when(ruleChainService).findRuleChainById(eq(tenantId), any()); doReturn(ruleChain).when(ruleChainService).findRuleChainById(eq(tenantId), any());
break; break;
case ENTITY_VIEW: case ENTITY_VIEW:
EntityView entityView = new EntityView(); EntityView entityView = new EntityView();
entityView.setTenantId(tenantId); entityView.setTenantId(tenantId);
when(ctx.getEntityViewService()).thenReturn(entityViewService); when(ctx.getEntityViewService()).thenReturn(entityViewService);
doReturn(entityView).when(entityViewService).findEntityViewById(eq(tenantId), any()); doReturn(entityView).when(entityViewService).findEntityViewById(eq(tenantId), any());
break; break;
case DASHBOARD: case DASHBOARD:
Dashboard dashboard = new Dashboard(); Dashboard dashboard = new Dashboard();
dashboard.setTenantId(tenantId); dashboard.setTenantId(tenantId);
when(ctx.getDashboardService()).thenReturn(dashboardService); when(ctx.getDashboardService()).thenReturn(dashboardService);
doReturn(dashboard).when(dashboardService).findDashboardById(eq(tenantId), any()); doReturn(dashboard).when(dashboardService).findDashboardById(eq(tenantId), any());
break; break;
case EDGE: case EDGE:
Edge edge = new Edge(); Edge edge = new Edge();
edge.setTenantId(tenantId); edge.setTenantId(tenantId);
when(ctx.getEdgeService()).thenReturn(edgeService); when(ctx.getEdgeService()).thenReturn(edgeService);
doReturn(edge).when(edgeService).findEdgeById(eq(tenantId), any()); doReturn(edge).when(edgeService).findEdgeById(eq(tenantId), any());
break; break;
case OTA_PACKAGE: case OTA_PACKAGE:
OtaPackage otaPackage = new OtaPackage(); OtaPackage otaPackage = new OtaPackage();
otaPackage.setTenantId(tenantId); otaPackage.setTenantId(tenantId);
when(ctx.getOtaPackageService()).thenReturn(otaPackageService); when(ctx.getOtaPackageService()).thenReturn(otaPackageService);
doReturn(otaPackage).when(otaPackageService).findOtaPackageInfoById(eq(tenantId), any()); doReturn(otaPackage).when(otaPackageService).findOtaPackageInfoById(eq(tenantId), any());
break; break;
case ASSET_PROFILE: case ASSET_PROFILE:
AssetProfile assetProfile = new AssetProfile(); AssetProfile assetProfile = new AssetProfile();
assetProfile.setTenantId(tenantId); assetProfile.setTenantId(tenantId);
when(ctx.getAssetProfileCache()).thenReturn(assetProfileCache); when(ctx.getAssetProfileCache()).thenReturn(assetProfileCache);
doReturn(assetProfile).when(assetProfileCache).get(eq(tenantId), any(AssetProfileId.class)); doReturn(assetProfile).when(assetProfileCache).get(eq(tenantId), any(AssetProfileId.class));
break; break;
case DEVICE_PROFILE: case DEVICE_PROFILE:
DeviceProfile deviceProfile = new DeviceProfile(); DeviceProfile deviceProfile = new DeviceProfile();
deviceProfile.setTenantId(tenantId); deviceProfile.setTenantId(tenantId);
when(ctx.getDeviceProfileCache()).thenReturn(deviceProfileCache); when(ctx.getDeviceProfileCache()).thenReturn(deviceProfileCache);
doReturn(deviceProfile).when(deviceProfileCache).get(eq(tenantId), any(DeviceProfileId.class)); doReturn(deviceProfile).when(deviceProfileCache).get(eq(tenantId), any(DeviceProfileId.class));
break; break;
case WIDGET_TYPE: case WIDGET_TYPE:
WidgetType widgetType = new WidgetType(); WidgetType widgetType = new WidgetType();
widgetType.setTenantId(tenantId); widgetType.setTenantId(tenantId);
when(ctx.getWidgetTypeService()).thenReturn(widgetTypeService); when(ctx.getWidgetTypeService()).thenReturn(widgetTypeService);
doReturn(widgetType).when(widgetTypeService).findWidgetTypeById(eq(tenantId), any()); doReturn(widgetType).when(widgetTypeService).findWidgetTypeById(eq(tenantId), any());
break; break;
case WIDGETS_BUNDLE: case WIDGETS_BUNDLE:
WidgetsBundle widgetsBundle = new WidgetsBundle(); WidgetsBundle widgetsBundle = new WidgetsBundle();
widgetsBundle.setTenantId(tenantId); widgetsBundle.setTenantId(tenantId);
when(ctx.getWidgetBundleService()).thenReturn(widgetsBundleService); when(ctx.getWidgetBundleService()).thenReturn(widgetsBundleService);
doReturn(widgetsBundle).when(widgetsBundleService).findWidgetsBundleById(eq(tenantId), any()); doReturn(widgetsBundle).when(widgetsBundleService).findWidgetsBundleById(eq(tenantId), any());
break; break;
case RPC: case RPC:
Rpc rpc = new Rpc(); Rpc rpc = new Rpc();
rpc.setTenantId(tenantId); rpc.setTenantId(tenantId);
when(ctx.getRpcService()).thenReturn(rpcService); when(ctx.getRpcService()).thenReturn(rpcService);
doReturn(rpc).when(rpcService).findRpcById(eq(tenantId), any()); doReturn(rpc).when(rpcService).findRpcById(eq(tenantId), any());
break; break;
case QUEUE: case QUEUE:
Queue queue = new Queue(); Queue queue = new Queue();
queue.setTenantId(tenantId); queue.setTenantId(tenantId);
when(ctx.getQueueService()).thenReturn(queueService); when(ctx.getQueueService()).thenReturn(queueService);
doReturn(queue).when(queueService).findQueueById(eq(tenantId), any()); doReturn(queue).when(queueService).findQueueById(eq(tenantId), any());
break; break;
case API_USAGE_STATE: case API_USAGE_STATE:
ApiUsageState apiUsageState = new ApiUsageState(); ApiUsageState apiUsageState = new ApiUsageState();
apiUsageState.setTenantId(tenantId); apiUsageState.setTenantId(tenantId);
when(ctx.getRuleEngineApiUsageStateService()).thenReturn(ruleEngineApiUsageStateService); when(ctx.getRuleEngineApiUsageStateService()).thenReturn(ruleEngineApiUsageStateService);
doReturn(apiUsageState).when(ruleEngineApiUsageStateService).findApiUsageStateById(eq(tenantId), any()); doReturn(apiUsageState).when(ruleEngineApiUsageStateService).findApiUsageStateById(eq(tenantId), any());
break; break;
case TB_RESOURCE: case TB_RESOURCE:
TbResource tbResource = new TbResource(); TbResource tbResource = new TbResource();
tbResource.setTenantId(tenantId); tbResource.setTenantId(tenantId);
when(ctx.getResourceService()).thenReturn(resourceService); when(ctx.getResourceService()).thenReturn(resourceService);
doReturn(tbResource).when(resourceService).findResourceInfoById(eq(tenantId), any()); doReturn(tbResource).when(resourceService).findResourceInfoById(eq(tenantId), any());
break; break;
case RULE_NODE: case RULE_NODE:
RuleNode ruleNode = new RuleNode(); RuleNode ruleNode = new RuleNode();
when(ctx.getRuleChainService()).thenReturn(ruleChainService); when(ctx.getRuleChainService()).thenReturn(ruleChainService);
doReturn(ruleNode).when(ruleChainService).findRuleNodeById(eq(tenantId), any()); doReturn(ruleNode).when(ruleChainService).findRuleNodeById(eq(tenantId), any());
break; break;
case TENANT_PROFILE: case TENANT_PROFILE:
TenantProfile tenantProfile = new TenantProfile(tenantProfileId); TenantProfile tenantProfile = new TenantProfile(tenantProfileId);
when(ctx.getTenantProfile()).thenReturn(tenantProfile); when(ctx.getTenantProfile()).thenReturn(tenantProfile);
break; break;
case NOTIFICATION_TARGET: case NOTIFICATION_TARGET:
NotificationTarget notificationTarget = new NotificationTarget(); NotificationTarget notificationTarget = new NotificationTarget();
@ -431,6 +395,12 @@ public class TenantIdLoaderTest {
when(ctx.getAiModelService()).thenReturn(aiModelService); when(ctx.getAiModelService()).thenReturn(aiModelService);
doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any()); doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any());
break; 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: default:
throw new RuntimeException("Unexpected originator EntityType " + entityType); 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