Browse Source

Introduce API-key auth

pull/14074/head
Andrii Landiak 8 months ago
parent
commit
37d6c5dabd
  1. 50
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  2. 145
      application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java
  3. 19
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  4. 1
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  5. 5
      application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java
  6. 11
      application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java
  7. 18
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java
  8. 31
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/ApiKeyHeaderTokenExtractor.java
  9. 29
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtHeaderTokenExtractor.java
  10. 5
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/JwtQueryTokenExtractor.java
  11. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/TokenExtractor.java
  12. 3
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java
  13. 23
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java
  14. 14
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java
  15. 12
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java
  16. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java
  17. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java
  18. 2
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java
  19. 1
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java
  20. 1
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java
  21. 93
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java
  22. 61
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java
  23. 87
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java
  24. 3
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java
  25. 11
      application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java
  26. 2
      application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
  27. 5
      application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java
  28. 14
      application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java
  29. 20
      application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java
  30. 5
      application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java
  31. 3
      application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
  32. 18
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  33. 3
      application/src/main/resources/thingsboard.yml
  34. 144
      application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java
  35. 14
      application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java
  36. 7
      application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java
  37. 189
      application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java
  38. 43
      common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java
  39. 1
      common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
  40. 9
      common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java
  41. 20
      common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
  42. 46
      common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java
  43. 1
      common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
  44. 63
      common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java
  45. 82
      common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java
  46. 4
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java
  47. 1
      common/proto/src/main/proto/queue.proto
  48. 11
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  49. 81
      dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractApiKeyInfoEntity.java
  50. 51
      dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyEntity.java
  51. 46
      dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiKeyInfoEntity.java
  52. 40
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java
  53. 33
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java
  54. 33
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java
  55. 19
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java
  56. 29
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyInfoDao.java
  57. 36
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java
  58. 159
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java
  59. 77
      dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java
  60. 34
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyInfoRepository.java
  61. 53
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java
  62. 73
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java
  63. 58
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyInfoDao.java
  64. 2
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  65. 4
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  66. 6
      dao/src/main/resources/sql/schema-entities-idx.sql
  67. 12
      dao/src/main/resources/sql/schema-entities.sql
  68. 234
      dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java
  69. 3
      dao/src/test/resources/application-test.properties
  70. 52
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

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

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

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

@ -0,0 +1,145 @@
/**
* 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 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.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.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
import static org.thingsboard.server.controller.ControllerConstants.API_KEY_ID_PARAM_DESCRIPTION;
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.TENANT_AUTHORITY_PARAGRAPH;
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 <hash>'." + TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping(value = "/apiKey")
public String saveApiKey(
@Parameter(description = "A JSON value representing the Api Key token.")
@RequestBody @Valid ApiKeyInfo apiKeyInfo) throws ThingsboardException {
SecurityUser securityUser = getCurrentUser();
apiKeyInfo.setTenantId(securityUser.getTenantId());
apiKeyInfo.setUserId(securityUser.getId());
checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY);
return toUserApiKey(checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo)).getHash());
}
@ApiOperation(value = "Get User Api Keys (getUserApiKeys)",
notes = "Returns a page of api keys owned by user. " +
PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@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) throws ThingsboardException {
SecurityUser securityUser = getCurrentUser();
PageLink pageLink = createPageLink(pageSize, page, null, null, null);
UserId userId = new UserId(toUUID(userIdStr));
accessControlService.checkPermission(securityUser, Resource.API_KEY, Operation.READ);
return apiKeyService.findApiKeysByUserId(securityUser.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." + TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@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);
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. " + TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@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);
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." + TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@DeleteMapping(value = "/apiKey/{id}")
public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException {
ApiKeyId apiKeyId = new ApiKeyId(id);
ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.DELETE);
apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false);
}
private String toUserApiKey(String hash) {
return API_KEY_HEADER_PREFIX + hash;
}
}

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

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

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

@ -64,6 +64,7 @@ public class ControllerConstants {
protected static final String WIDGET_TYPE_ID_PARAM_DESCRIPTION = "A string value representing the widget type id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String VC_REQUEST_ID_PARAM_DESCRIPTION = "A string value representing the version control request id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String RESOURCE_ID_PARAM_DESCRIPTION = "A string value representing the resource id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String API_KEY_ID_PARAM_DESCRIPTION = "A string value representing the api key id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'";
protected static final String SYSTEM_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' authority.";
protected static final String SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' or 'TENANT_ADMIN' authority.";
protected static final String TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'TENANT_ADMIN' authority.";

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

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

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

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

18
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,17 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth.jwt.extractor;
package org.thingsboard.server.service.security.auth.extractor;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.config.ThingsboardSecurityConfiguration;
@Component(value="jwtHeaderTokenExtractor")
public class JwtHeaderTokenExtractor implements TokenExtractor {
public static final String HEADER_PREFIX = "Bearer ";
public abstract class AbstractHeaderTokenExtractor implements TokenExtractor {
private final String headerPrefix;
protected AbstractHeaderTokenExtractor(String headerPrefix) {
this.headerPrefix = headerPrefix;
}
@Override
public String extract(HttpServletRequest request) {
@ -35,10 +38,11 @@ public class JwtHeaderTokenExtractor implements TokenExtractor {
}
}
if (header.length() < HEADER_PREFIX.length()) {
if (header.length() < headerPrefix.length()) {
throw new AuthenticationServiceException("Invalid authorization header size.");
}
return header.substring(HEADER_PREFIX.length(), header.length());
return header.substring(headerPrefix.length());
}
}

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

@ -0,0 +1,31 @@
/**
* 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.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
@Component
@Qualifier("apiKeyHeaderTokenExtractor")
public class ApiKeyHeaderTokenExtractor extends AbstractHeaderTokenExtractor {
public ApiKeyHeaderTokenExtractor() {
super(API_KEY_HEADER_PREFIX);
}
}

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

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

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

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

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

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

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

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

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

@ -28,12 +28,17 @@ import org.springframework.security.web.authentication.AbstractAuthenticationPro
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.thingsboard.server.service.security.auth.JwtAuthenticationToken;
import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor;
import org.thingsboard.server.service.security.auth.extractor.TokenExtractor;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import java.io.IOException;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2;
public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private final AuthenticationFailureHandler failureHandler;
private final TokenExtractor tokenExtractor;
@ -46,8 +51,7 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token));
}
@ -61,10 +65,23 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
chain.doFilter(request, response);
}
@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (!super.requiresAuthentication(request, response)) {
return false;
}
String header = request.getHeader(JWT_TOKEN_HEADER_PARAM);
if (header == null) {
header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2);
}
return header != null && header.startsWith(BEARER_HEADER_PREFIX);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
}

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

@ -57,17 +57,17 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.notNull(authentication, "No authentication data provided");
RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();
SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.getToken());
SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken.token());
UserPrincipal principal = unsafeUser.getUserPrincipal();
SecurityUser securityUser;
if (principal.getType() == UserPrincipal.Type.USER_NAME) {
if (principal.getType() == UserPrincipal.Type.USER_NAME) {
securityUser = authenticateByUserId(unsafeUser.getId());
} else {
securityUser = authenticateByPublicId(principal.getValue());
}
securityUser.setSessionId(unsafeUser.getSessionId());
if (tokenOutdatingService.isOutdated(rawAccessToken.getToken(), securityUser.getId())) {
if (tokenOutdatingService.isOutdated(rawAccessToken.token(), securityUser.getId())) {
throw new CredentialsExpiredException("Token is outdated");
}
@ -93,9 +93,8 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned");
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
return securityUser;
return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
}
private SecurityUser authenticateByPublicId(String publicId) {
@ -125,13 +124,12 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId);
SecurityUser securityUser = new SecurityUser(user, true, userPrincipal);
return securityUser;
return new SecurityUser(user, true, userPrincipal);
}
@Override
public boolean supports(Class<?> authentication) {
return (RefreshAuthenticationToken.class.isAssignableFrom(authentication));
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,93 @@
/**
* 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.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.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.RawApiKeyToken;
@Component
@RequiredArgsConstructor
public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider {
private final ApiKeyService apiKeyService;
private final UserService userService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
RawApiKeyToken raw = (RawApiKeyToken) authentication.getCredentials();
SecurityUser securityUser = authenticate(raw.token());
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.findApiKeyByHash(key);
if (apiKey == null) {
throw new UsernameNotFoundException("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 BadCredentialsException("API key is expired");
}
TenantId tenantId = apiKey.getTenantId();
UserId userId = apiKey.getUserId();
User user = userService.findUserById(tenantId, userId);
if (user == null) {
throw new UsernameNotFoundException("User for the provided API key is no longer exists");
}
UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId);
if (userCredentials == null) {
throw new UsernameNotFoundException("User credentials not found");
}
if (!userCredentials.isEnabled()) {
throw new DisabledException("User is not active");
}
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, userCredentials.isEnabled(), 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.RawApiKeyToken;
import java.io.Serial;
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 2978710889397403536L;
private RawApiKeyToken rawApiKeyToken;
private SecurityUser securityUser;
public ApiKeyAuthenticationToken(RawApiKeyToken raw) {
super(null);
this.rawApiKeyToken = raw;
setAuthenticated(false);
}
public ApiKeyAuthenticationToken(SecurityUser securityUser) {
super(securityUser.getAuthorities());
this.eraseCredentials();
this.securityUser = securityUser;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return rawApiKeyToken;
}
@Override
public Object getPrincipal() {
return this.securityUser;
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.rawApiKeyToken = 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.RawApiKeyToken;
import java.io.IOException;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_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 {
RawApiKeyToken token = new RawApiKeyToken(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(token));
}
@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(JWT_TOKEN_HEADER_PARAM);
if (header == null) {
header = request.getHeader(JWT_TOKEN_HEADER_PARAM_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);
}
}

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

@ -55,7 +55,7 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
.flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30));
tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken());
tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).token());
tokenPair.setRefreshToken(null);
tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN);
} else {
@ -83,4 +83,5 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}

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

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

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

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

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

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

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

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

20
application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java

@ -0,0 +1,20 @@
/**
* 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 RawApiKeyToken(String token) {
}

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

@ -33,7 +33,6 @@ import java.util.Optional;
@Slf4j
public class DefaultAccessControlService implements AccessControlService {
private static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!";
private final Map<Authority, Permissions> authorityPermissions = new HashMap<>();
@ -58,7 +57,7 @@ public class DefaultAccessControlService implements AccessControlService {
@Override
@SuppressWarnings("unchecked")
public <I extends EntityId, T extends HasTenantId> void checkPermission(SecurityUser user, Resource resource,
Operation operation, I entityId, T entity) throws ThingsboardException {
Operation operation, I entityId, T entity) throws ThingsboardException {
PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource);
if (!permissionChecker.hasPermission(user, operation, entityId, entity)) {
permissionDenied();
@ -71,7 +70,7 @@ public class DefaultAccessControlService implements AccessControlService {
permissionDenied();
}
Optional<PermissionChecker> permissionChecker = permissions.getPermissionChecker(resource);
if (!permissionChecker.isPresent()) {
if (permissionChecker.isEmpty()) {
permissionDenied();
}
return permissionChecker.get();

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

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

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

@ -20,8 +20,11 @@ import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -59,6 +62,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.JOB, tenantEntityPermissionChecker);
put(Resource.AI_MODEL, aiModelPermissionChecker);
put(Resource.API_KEY, apiKeysPermissionChecker);
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {
@ -163,4 +167,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());
}
};
}

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

@ -662,6 +662,9 @@ cache:
aiModel:
timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL
maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled
apiKeys:
timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL
maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled
# Deliberately placed outside the 'specs' group above
notificationRules:

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.ApiKeyInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.UUID;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
@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);
String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, String.class);
Assert.assertTrue(apiKeyStr.startsWith(API_KEY_HEADER_PREFIX));
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, String.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, String.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, String.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, String.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);
return apiKeyInfo;
}
}

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

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

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

@ -114,12 +114,12 @@ public class TokenOutdatingTest {
// Token outdatage time is rounded to 1 sec. Need to wait before outdating so that outdatage time is strictly after token issue time
SECONDS.sleep(1);
eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId()));
assertTrue(tokenOutdatingService.isOutdated(jwtToken.getToken(), securityUser.getId()));
assertTrue(tokenOutdatingService.isOutdated(jwtToken.token(), securityUser.getId()));
SECONDS.sleep(1);
JwtToken newJwtToken = tokenFactory.createAccessJwtToken(securityUser);
assertFalse(tokenOutdatingService.isOutdated(newJwtToken.getToken(), securityUser.getId()));
assertFalse(tokenOutdatingService.isOutdated(newJwtToken.token(), securityUser.getId()));
}
@Test
@ -229,7 +229,7 @@ public class TokenOutdatingTest {
private RawAccessJwtToken getRawJwtToken(JwtToken token) {
return new RawAccessJwtToken(token.getToken());
return new RawAccessJwtToken(token.token());
}
private SecurityUser createMockSecurityUser(UserId userId) {
@ -241,4 +241,5 @@ public class TokenOutdatingTest {
securityUser.setSessionId(UUID.randomUUID().toString());
return securityUser;
}
}

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

@ -0,0 +1,189 @@
/**
* 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.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.thingsboard.server.common.data.User;
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.ApiKey;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.RawApiKeyToken;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class ApiKeyAuthenticationProviderTest {
private static final String TEST_API_KEY = "test_api_key";
private static final String USER_EMAIL = "test@example.com";
@Mock
private ApiKeyService apiKeyService;
@Mock
private UserService userService;
private ApiKeyAuthenticationProvider provider;
private TenantId tenantId;
private UserId userId;
private User user;
private UserCredentials userCredentials;
private ApiKey apiKey;
@Before
public void setUp() {
provider = new ApiKeyAuthenticationProvider(apiKeyService, userService);
tenantId = TenantId.fromUUID(UUID.randomUUID());
userId = new UserId(UUID.randomUUID());
user = new User();
user.setId(userId);
user.setTenantId(tenantId);
user.setEmail(USER_EMAIL);
user.setAuthority(Authority.TENANT_ADMIN);
userCredentials = new UserCredentials();
userCredentials.setEnabled(true);
apiKey = new ApiKey();
apiKey.setId(new ApiKeyId(UUID.randomUUID()));
apiKey.setTenantId(tenantId);
apiKey.setUserId(userId);
apiKey.setHash(TEST_API_KEY);
apiKey.setEnabled(true);
apiKey.setExpirationTime(0);
}
@Test
public void testSuccessfulAuthentication() {
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
when(userService.findUserById(tenantId, userId)).thenReturn(user);
when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
Authentication authentication = provider.authenticate(token);
assertNotNull(authentication);
assertTrue(authentication.isAuthenticated());
assertTrue(authentication instanceof ApiKeyAuthenticationToken);
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
assertEquals(userId, securityUser.getId());
assertEquals(tenantId, securityUser.getTenantId());
assertEquals(USER_EMAIL, securityUser.getEmail());
assertEquals(Authority.TENANT_ADMIN, securityUser.getAuthority());
}
@Test(expected = BadCredentialsException.class)
public void testEmptyApiKey() {
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(""));
provider.authenticate(token);
}
@Test(expected = UsernameNotFoundException.class)
public void testNonExistentApiKey() {
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(null);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test(expected = DisabledException.class)
public void testDisabledApiKey() {
apiKey.setEnabled(false);
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test(expected = BadCredentialsException.class)
public void testExpiredApiKey() {
apiKey.setExpirationTime(System.currentTimeMillis() - 10000); // Expired 10 seconds ago
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test(expected = UsernameNotFoundException.class)
public void testNonExistentUser() {
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
when(userService.findUserById(tenantId, userId)).thenReturn(null);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test(expected = UsernameNotFoundException.class)
public void testNonExistentUserCredentials() {
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
when(userService.findUserById(tenantId, userId)).thenReturn(user);
when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(null);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test(expected = DisabledException.class)
public void testDisabledUser() {
userCredentials.setEnabled(false);
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
when(userService.findUserById(tenantId, userId)).thenReturn(user);
when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test(expected = InsufficientAuthenticationException.class)
public void testUserWithoutAuthority() {
user.setAuthority(null);
when(apiKeyService.findApiKeyByHash(TEST_API_KEY)).thenReturn(apiKey);
when(userService.findUserById(tenantId, userId)).thenReturn(user);
when(userService.findUserCredentialsByUserId(tenantId, userId)).thenReturn(userCredentials);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test
public void testSupports() {
assertTrue(provider.supports(ApiKeyAuthenticationToken.class));
}
}

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

@ -0,0 +1,43 @@
/**
* 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 deleteByTenantId(TenantId tenantId);
void deleteByUserId(TenantId tenantId, UserId userId);
ApiKey findApiKeyByHash(String hash);
ApiKey findApiKeyById(TenantId tenantId, ApiKeyId apiKeyId);
PageData<ApiKeyInfo> findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink);
}

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

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

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

@ -17,6 +17,7 @@ package org.thingsboard.server.common.data;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import java.util.Arrays;
import java.util.EnumSet;
@ -69,6 +70,12 @@ public enum EntityType {
public String getNormalName() {
return "AI model";
}
},
API_KEY(44, "api_key") {
@Override
public String getNormalName() {
return "API key";
}
};
@Getter
@ -76,7 +83,7 @@ public enum EntityType {
@Getter
private final String tableName;
@Getter
private final String normalName = StringUtils.capitalize(StringUtils.removeStart(name(), "TB_")
private final String normalName = StringUtils.capitalize(Strings.CS.removeStart(name(), "TB_")
.toLowerCase().replaceAll("_", " "));
public static final List<String> NORMAL_NAMES = EnumSet.allOf(EntityType.class).stream()

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

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

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

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

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

@ -0,0 +1,63 @@
/**
* 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.JsonIgnore;
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 hash value", requiredMode = Schema.RequiredMode.REQUIRED)
@JsonIgnore
private String hash;
public ApiKey() {
super();
}
public ApiKey(ApiKeyId id) {
super(id);
}
public ApiKey(ApiKey apiKey) {
super(apiKey);
this.hash = apiKey.getHash();
}
public ApiKey(ApiKeyInfo apiKeyInfo) {
super(apiKeyInfo);
this.hash = null;
}
public ApiKey(ApiKeyInfo apiKeyInfo, String hash) {
super(apiKeyInfo);
this.hash = hash;
}
}

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

@ -0,0 +1,82 @@
/**
* 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.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.", accessMode = Schema.AccessMode.READ_ONLY)
private UserId userId;
@Schema(description = "Expiration time of the api key.")
private long expirationTime;
@NoXss
@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;
@Schema(description = "JSON object with the Api Key Id. " +
"Specify this field to update the Api Key. " +
"Referencing non-existing Api Key Id will cause error. " +
"Omit this field to create new Api Key.")
@Override
public ApiKeyId getId() {
return super.getId();
}
public ApiKeyInfo() {
super();
}
public ApiKeyInfo(ApiKeyId id) {
super(id);
}
public ApiKeyInfo(ApiKeyInfo apiKeyInfo) {
super(apiKeyInfo);
this.tenantId = apiKeyInfo.getTenantId();
this.userId = apiKeyInfo.getUserId();
this.expirationTime = apiKeyInfo.getExpirationTime();
this.enabled = apiKeyInfo.isEnabled();
this.description = apiKeyInfo.getDescription();
}
}

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

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

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

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

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

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

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

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.model.sql;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.id.ApiKeyId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.model.BaseEntity;
import org.thingsboard.server.dao.model.BaseSqlEntity;
import java.util.UUID;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_DESCRIPTION_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_ENABLED_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_EXPIRATION_TIME_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_TENANT_ID_COLUMN_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_USER_ID_COLUMN_NAME;
@Data
@EqualsAndHashCode(callSuper = true)
@MappedSuperclass
public abstract class AbstractApiKeyInfoEntity<T extends ApiKeyInfo> extends BaseSqlEntity<T> implements BaseEntity<T> {
@Column(name = API_KEY_TENANT_ID_COLUMN_NAME)
private UUID tenantId;
@Column(name = API_KEY_USER_ID_COLUMN_NAME)
private UUID userId;
@Column(name = API_KEY_EXPIRATION_TIME_COLUMN_NAME)
private long expirationTime;
@Column(name = API_KEY_ENABLED_COLUMN_NAME)
private boolean enabled;
@Column(name = API_KEY_DESCRIPTION_COLUMN_NAME)
private String description;
public AbstractApiKeyInfoEntity() {
super();
}
public AbstractApiKeyInfoEntity(ApiKeyInfo apiKeyInfo) {
super(apiKeyInfo);
this.tenantId = apiKeyInfo.getTenantId().getId();
this.userId = apiKeyInfo.getUserId().getId();
this.expirationTime = apiKeyInfo.getExpirationTime();
this.description = apiKeyInfo.getDescription();
this.enabled = apiKeyInfo.isEnabled();
}
protected ApiKeyInfo toApiKeyInfo() {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo(new ApiKeyId(getUuid()));
apiKeyInfo.setCreatedTime(createdTime);
apiKeyInfo.setTenantId(TenantId.fromUUID(tenantId));
apiKeyInfo.setUserId(new UserId(userId));
apiKeyInfo.setEnabled(enabled);
apiKeyInfo.setExpirationTime(expirationTime);
apiKeyInfo.setDescription(description);
return apiKeyInfo;
}
}

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

@ -0,0 +1,51 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.model.sql;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.pat.ApiKey;
import static org.thingsboard.server.dao.model.ModelConstants.API_KEY_HASH_COLUMN_NAME;
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 ApiKeyEntity extends AbstractApiKeyInfoEntity<ApiKey> {
@Column(name = API_KEY_HASH_COLUMN_NAME)
private String hash;
public ApiKeyEntity() {
super();
}
public ApiKeyEntity(ApiKey apiKey) {
super(apiKey);
this.hash = apiKey.getHash();
}
@Override
public ApiKey toData() {
return new ApiKey(super.toApiKeyInfo(), hash);
}
}

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

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 hash) implements Serializable {
ApiKeyCacheKey {
requireNonNull(hash);
}
static ApiKeyCacheKey of(String hash) {
return new ApiKeyCacheKey(hash);
}
@NonNull
@Override
public String toString() {
return /* cache name */ "_" + hash;
}
}

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

33
dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.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.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 findByHash(String hash);
Set<String> deleteByTenantId(TenantId tenantId);
Set<String> deleteByUserId(TenantId tenantId, UserId userId);
}

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

@ -0,0 +1,19 @@
/**
* 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 hash) {
}

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

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

@ -0,0 +1,159 @@
/**
* 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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 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 DEFAULT_API_KEY_BYTES = 32;
private final ApiKeyDao apiKeyDao;
private final ApiKeyInfoDao apiKeyInfoDao;
private final ApiKeyDataValidator apiKeyValidator;
@Override
@TransactionalEventListener
public void handleEvictEvent(ApiKeyEvictEvent event) {
cache.evict(ApiKeyCacheKey.of(event.hash()));
}
@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 hash = generateApiKeySecret();
apiKey.setHash(hash);
} else {
apiKey.setHash(old.getHash());
}
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.getHash()));
}
return savedApiKey;
} catch (Exception e) {
checkConstraintViolation(e, "api_hash_unq_key", "Api Key with such hash 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 void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force) {
deleteApiKey(tenantId, apiKey.getId());
}
@Override
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteApiKey(tenantId, id);
}
private void deleteApiKey(TenantId tenantId, EntityId entityId) {
UUID apiKeyId = entityId.getId();
validateId(apiKeyId, id -> INCORRECT_API_KEY_ID + id);
ApiKey apiKey = apiKeyDao.findById(tenantId, apiKeyId);
if (apiKey == null) {
return;
}
apiKeyDao.removeById(tenantId, apiKeyId);
publishEvictEvent(new ApiKeyEvictEvent(apiKey.getHash()));
}
@Override
public void deleteByTenantId(TenantId tenantId) {
log.trace("Executing deleteApiKeysByTenantId, tenantId [{}]", tenantId);
validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
Set<String> hashes = apiKeyDao.deleteByTenantId(tenantId);
hashes.forEach(hash -> publishEvictEvent(new ApiKeyEvictEvent(hash)));
}
@Override
public void deleteByUserId(TenantId tenantId, UserId userId) {
log.trace("Executing deleteApiKeysByUserId, tenantId [{}]", tenantId);
validateId(userId, id -> INCORRECT_USER_ID + id);
Set<String> hashes = apiKeyDao.deleteByUserId(tenantId, userId);
hashes.forEach(hash -> publishEvictEvent(new ApiKeyEvictEvent(hash)));
}
@Override
public ApiKey findApiKeyByHash(String hash) {
log.trace("Executing findApiKeyByHash [{}]", hash);
var cacheKey = ApiKeyCacheKey.of(hash);
return cache.getAndPutInTransaction(cacheKey, () -> apiKeyDao.findByHash(hash), true);
}
private static String generateApiKeySecret() {
return StringUtils.generateSafeToken(DEFAULT_API_KEY_BYTES);
}
@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.TenantDao;
import org.thingsboard.server.dao.user.UserDao;
@Component
@RequiredArgsConstructor
public class ApiKeyDataValidator extends DataValidator<ApiKey> {
private final ApiKeyDao apiKeyDao;
private final TenantDao tenantDao;
private final UserDao userDao;
@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 (tenantDao.findById(apiKey.getTenantId(), apiKey.getTenantId().getId()) == null) {
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 (userDao.findById(tenantId, apiKey.getUserId().getId()) == 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;
}
}

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

@ -0,0 +1,34 @@
/**
* 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 k FROM ApiKeyInfoEntity k WHERE k.tenantId = :tenantId AND k.userId = :userId")
Page<ApiKeyInfoEntity> findByUserId(@Param("tenantId") UUID tenantId,
@Param("userId") UUID userId,
Pageable pageable);
}

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

@ -0,0 +1,53 @@
/**
* 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 findByHash(String hash);
@Transactional
@Modifying
@Query(value = """
DELETE FROM api_key
WHERE tenant_id = :tenantId
RETURNING hash
""", 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 hash
""", nativeQuery = true
)
Set<String> deleteByUserId(@Param("tenantId") UUID tenantId,
@Param("userId") UUID userId);
}

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

@ -0,0 +1,73 @@
/**
* 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 findByHash(String hash) {
return DaoUtil.getData(apiKeyRepository.findByHash(hash));
}
@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
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(), DaoUtil.toPageable(pageLink)));
}
@Override
protected Class<ApiKeyInfoEntity> getEntityClass() {
return ApiKeyInfoEntity.class;
}
@Override
protected JpaRepository<ApiKeyInfoEntity, UUID> getRepository() {
return apiKeyInfoRepository;
}
}

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

@ -179,7 +179,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService<TenantId, Ten
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE, EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_TARGET, EntityType.QUEUE_STATS, EntityType.CUSTOMER,
EntityType.DOMAIN, EntityType.MOBILE_APP_BUNDLE, EntityType.MOBILE_APP, EntityType.OAUTH2_CLIENT,
EntityType.AI_MODEL
EntityType.AI_MODEL, EntityType.API_KEY
);
}

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

@ -58,6 +58,8 @@ import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.pat.ApiKeyDao;
import org.thingsboard.server.dao.pat.ApiKeyService;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
@ -96,6 +98,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
private final UserAuthSettingsDao userAuthSettingsDao;
private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao;
private final ApiKeyService apiKeyService;
private final SecuritySettingsService securitySettingsService;
private final DataValidator<User> userValidator;
private final DataValidator<UserCredentials> userCredentialsValidator;
@ -332,6 +335,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
validateId(userId, id -> INCORRECT_USER_ID + id);
userCredentialsDao.removeByUserId(tenantId, userId);
userAuthSettingsDao.removeByUserId(userId);
apiKeyService.deleteByUserId(tenantId, userId);
publishEvictEvent(new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), null));
userSettingsDao.removeByUserId(tenantId, userId);
userDao.removeById(tenantId, userId.getId());

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

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

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

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

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

@ -0,0 +1,234 @@
/**
* 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.getHash());
}
@Test
public void testSaveApiKeyWithEmptyDescription() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(null);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
Assert.assertNotNull(savedApiKey);
Assert.assertNotNull(savedApiKey.getId());
Assert.assertEquals(tenantId, savedApiKey.getTenantId());
Assert.assertNull(savedApiKey.getDescription());
Assert.assertTrue(savedApiKey.isEnabled());
Assert.assertNotNull(savedApiKey.getHash());
}
@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.getHash(), updatedApiKey.getHash());
}
@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.getHash(), foundApiKey.getHash());
}
@Test
public void testFindApiKeyByHash() {
ApiKeyInfo apiKeyInfo = createApiKeyInfo(TEST_API_KEY_DESCRIPTION);
ApiKey savedApiKey = apiKeyService.saveApiKey(tenantId, apiKeyInfo);
ApiKey foundApiKey = apiKeyService.findApiKeyByHash(savedApiKey.getHash());
Assert.assertNotNull(foundApiKey);
Assert.assertEquals(savedApiKey.getId(), foundApiKey.getId());
Assert.assertEquals(savedApiKey.getDescription(), foundApiKey.getDescription());
Assert.assertEquals(savedApiKey.isEnabled(), foundApiKey.isEnabled());
Assert.assertEquals(savedApiKey.getHash(), foundApiKey.getHash());
}
@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() {
// Create 3 API keys for the user
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());
// delete by user id
apiKeyService.deleteByUserId(tenantId, userId);
pageData = apiKeyService.findApiKeysByUserId(tenantId, userId, new PageLink(10));
Assert.assertNotNull(pageData);
Assert.assertEquals(0, pageData.getData().size());
Assert.assertEquals(0, pageData.getTotalElements());
}
private ApiKeyInfo createApiKeyInfo(String description) {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setTenantId(tenantId);
apiKeyInfo.setUserId(userId);
apiKeyInfo.setDescription(description);
apiKeyInfo.setEnabled(true);
return apiKeyInfo;
}
}

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

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

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

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

Loading…
Cancel
Save