Browse Source

UserAuthDetailsCache. Refactoring after review

pull/14074/head
Andrii Landiak 12 months ago
parent
commit
1f884bb5ca
  1. 20
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  2. 31
      application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java
  3. 8
      application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java
  4. 3
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
  5. 4
      application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java
  6. 8
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java
  7. 32
      application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java
  8. 34
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java
  9. 12
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java
  10. 14
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java
  11. 3
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
  12. 4
      application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java
  13. 8
      application/src/main/java/org/thingsboard/server/service/ttl/ApiKeysCleanUpService.java
  14. 85
      application/src/main/java/org/thingsboard/server/service/user/cache/DefaultUserAuthDetailsCache.java
  15. 26
      application/src/main/java/org/thingsboard/server/service/user/cache/UserAuthDetailsCache.java
  16. 13
      application/src/main/resources/thingsboard.yml
  17. 55
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  18. 4
      application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java
  19. 14
      application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java
  20. 208
      application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java
  21. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  22. 1
      common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
  23. 18
      common/data/src/main/java/org/thingsboard/server/common/data/UserAuthDetails.java
  24. 3
      common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java
  25. 3
      dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java
  26. 40
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCacheKey.java
  27. 33
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyCaffeineCache.java
  28. 6
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java
  29. 18
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyEvictEvent.java
  30. 36
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyRedisCache.java
  31. 66
      dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java
  32. 18
      dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiKeyDataValidator.java
  33. 21
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java
  34. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java
  35. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java
  36. 9
      dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java
  37. 2
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  38. 3
      dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java
  39. 10
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  40. 4
      dao/src/main/resources/sql/schema-entities.sql
  41. 10
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

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

@ -61,9 +61,9 @@ import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilt
import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter;
import org.thingsboard.server.transport.http.config.PayloadSizeFilter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
@Configuration
@EnableWebSecurity
@ -72,8 +72,8 @@ import java.util.List;
@TbCoreComponent
public class ThingsboardSecurityConfiguration {
public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization";
public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization";
public static final String AUTHORIZATION_HEADER = "X-Authorization";
public static final String AUTHORIZATION_HEADER_V2 = "Authorization";
public static final String JWT_TOKEN_QUERY_PARAM = "token";
public static final String API_KEY_HEADER_PREFIX = "ApiKey ";
@ -192,10 +192,16 @@ public class ThingsboardSecurityConfiguration {
}
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));
List<String> pathsToSkip = Stream.concat(
Arrays.stream(NON_TOKEN_BASED_AUTH_ENTRY_POINTS),
Stream.of(
WS_ENTRY_POINT,
TOKEN_REFRESH_ENTRY_POINT,
FORM_BASED_LOGIN_ENTRY_POINT,
PUBLIC_LOGIN_ENTRY_POINT,
DEVICE_API_ENTRY_POINT,
MAIL_OAUTH2_PROCESSING_ENTRY_POINT,
DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT)).toList();
return new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
}

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

@ -46,12 +46,11 @@ 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.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION;
@RestController
@ -64,8 +63,8 @@ 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>'." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')")
notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey <hash>'." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@PostMapping(value = "/apiKey")
public ApiKey saveApiKey(
@Parameter(description = "A JSON value representing the Api Key token.")
@ -73,15 +72,13 @@ public class ApiKeyController extends BaseController {
SecurityUser securityUser = getCurrentUser();
apiKeyInfo.setTenantId(securityUser.getTenantId());
checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY);
ApiKey savedApiKey = checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo));
savedApiKey.setValue(toUserApiKey(savedApiKey.getValue()));
return savedApiKey;
return checkNotNull(apiKeyService.saveApiKey(securityUser.getTenantId(), apiKeyInfo));
}
@ApiOperation(value = "Get User Api Keys (getUserApiKeys)",
notes = "Returns a page of api keys owned by user. " +
PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')")
PAGE_DATA_PARAMETERS + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/apiKeys/{userId}")
public PageData<ApiKeyInfo> getUserApiKeys(
@Parameter(description = USER_ID_PARAM_DESCRIPTION)
@ -100,8 +97,8 @@ public class ApiKeyController extends BaseController {
@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." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')")
"Referencing a non-existing ApiKey Id will cause a 'Not Found' error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@PutMapping("/apiKey/{id}/description")
public ApiKeyInfo updateApiKeyDescription(
@Parameter(description = API_KEY_ID_PARAM_DESCRIPTION, required = true)
@ -115,8 +112,8 @@ public class ApiKeyController extends BaseController {
}
@ApiOperation(value = "Enable or disable API key (enableApiKey)",
notes = "Updates api key with enabled = true/false. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')")
notes = "Updates api key with enabled = true/false. " + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@PutMapping(value = "/apiKey/{id}/enabled/{enabledValue}")
public ApiKeyInfo enableApiKey(
@Parameter(description = "Unique identifier of the API key to enable/disable", required = true)
@ -130,8 +127,8 @@ public class ApiKeyController extends BaseController {
}
@ApiOperation(value = "Delete API key by ID (deleteApiKey)",
notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')")
notes = "Deletes the API key. Referencing non-existing ApiKey Id will cause an error." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')")
@DeleteMapping(value = "/apiKey/{id}")
public void deleteApiKey(@PathVariable UUID id) throws ThingsboardException {
ApiKeyId apiKeyId = new ApiKeyId(id);
@ -139,8 +136,4 @@ public class ApiKeyController extends BaseController {
apiKeyService.deleteApiKey(apiKey.getTenantId(), apiKey, false);
}
private String toUserApiKey(String value) {
return API_KEY_HEADER_PREFIX + value;
}
}

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

@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.cf.CalculatedField;
@ -54,6 +55,7 @@ import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNotificationMsg;
import org.thingsboard.server.dao.edge.EdgeSynchronizationManager;
import org.thingsboard.server.dao.eventsourcing.ActionCause;
import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
@ -99,7 +101,7 @@ public class EntityStateSourcingListener {
case ASSET -> {
onAssetUpdate(event.getEntity(), event.getOldEntity());
}
case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE -> {
case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, USER -> {
tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent);
}
case RULE_CHAIN -> {
@ -164,7 +166,7 @@ public class EntityStateSourcingListener {
Asset asset = (Asset) event.getEntity();
tbClusterService.onAssetDeleted(tenantId, asset, null);
}
case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE -> {
case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, USER -> {
tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED);
}
case NOTIFICATION_REQUEST -> {
@ -228,6 +230,8 @@ public class EntityStateSourcingListener {
tbClusterService.onDeviceAssignedToTenant(tenant.getId(), device);
}
pushAssignedFromNotification(tenant, event.getTenantId(), device);
} else if (event.getActionType() == ActionType.CREDENTIALS_UPDATED && event.getEntityId() != null && event.getEntityId().getEntityType() == EntityType.USER) {
tbClusterService.broadcastEntityStateChangeEvent(event.getTenantId(), event.getEntityId(), ComponentLifecycleEvent.UPDATED);
}
}

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

@ -592,7 +592,8 @@ public class DefaultTbClusterService implements TbClusterService {
EntityType.TENANT_PROFILE,
EntityType.DEVICE_PROFILE,
EntityType.ASSET_PROFILE,
EntityType.JOB)
EntityType.JOB,
EntityType.USER)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
) {

4
application/src/main/java/org/thingsboard/server/service/security/auth/extractor/AbstractHeaderTokenExtractor.java

@ -30,9 +30,9 @@ public abstract class AbstractHeaderTokenExtractor implements TokenExtractor {
@Override
public String extract(HttpServletRequest request) {
String header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM);
String header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER);
if (StringUtils.isBlank(header)) {
header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2);
header = request.getHeader(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2);
if (StringUtils.isBlank(header)) {
throw new AuthenticationServiceException("Authorization header cannot be blank!");
}

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

@ -33,9 +33,9 @@ import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import java.io.IOException;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
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 {
@ -70,9 +70,9 @@ public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticati
if (!super.requiresAuthentication(request, response)) {
return false;
}
String header = request.getHeader(JWT_TOKEN_HEADER_PARAM);
String header = request.getHeader(AUTHORIZATION_HEADER);
if (header == null) {
header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2);
header = request.getHeader(AUTHORIZATION_HEADER_V2);
}
if (header == null) {
// If there is NO auth header at all, let the JWT filter try to attempt Authentication and failure in the process.

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

@ -28,28 +28,29 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken;
import org.thingsboard.server.service.security.auth.TokenOutdatingService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class RefreshTokenAuthenticationProvider implements AuthenticationProvider {
private final JwtTokenFactory tokenFactory;
private final UserService userService;
private final UserAuthDetailsCache userEnabledCache;
private final CustomerService customerService;
private final TokenOutdatingService tokenOutdatingService;
@ -61,7 +62,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
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());
@ -75,26 +76,21 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
}
private SecurityUser authenticateByUserId(UserId userId) {
TenantId systemId = TenantId.SYS_TENANT_ID;
User user = userService.findUserById(systemId, userId);
if (user == null) {
throw new UsernameNotFoundException("User not found by refresh token");
}
UserCredentials userCredentials = userService.findUserCredentialsByUserId(systemId, user.getId());
if (userCredentials == null) {
throw new UsernameNotFoundException("User credentials not found");
UserAuthDetails userAuthDetails = userEnabledCache.findUserEnabled(TenantId.SYS_TENANT_ID, userId);
if (userAuthDetails == null) {
throw new UsernameNotFoundException("User with credentials not found");
}
if (!userCredentials.isEnabled()) {
if (!userAuthDetails.credentialsEnabled()) {
throw new DisabledException("User is not active");
}
if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned");
User user = userAuthDetails.user();
if (user.getAuthority() == null) {
throw new InsufficientAuthenticationException("User has no authority assigned");
}
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
return new SecurityUser(user, true, userPrincipal);
}
private SecurityUser authenticateByPublicId(String publicId) {

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

@ -26,27 +26,25 @@ 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.UserAuthDetails;
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;
import org.thingsboard.server.service.security.model.token.RawApiKey;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
@Component
@RequiredArgsConstructor
public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider {
private final ApiKeyService apiKeyService;
private final UserService userService;
private final UserAuthDetailsCache userEnabledCache;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
RawApiKeyToken raw = (RawApiKeyToken) authentication.getCredentials();
SecurityUser securityUser = authenticate(raw.token());
RawApiKey rawApiKey = (RawApiKey) authentication.getCredentials();
SecurityUser securityUser = authenticate(rawApiKey.apiKey());
return new ApiKeyAuthenticationToken(securityUser);
}
@ -69,26 +67,20 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit
if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) {
throw new CredentialsExpiredException("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");
UserAuthDetails userAuthDetails = userEnabledCache.findUserEnabled(apiKey.getTenantId(), apiKey.getUserId());
if (userAuthDetails == null) {
throw new UsernameNotFoundException("User with credentials not found");
}
UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId);
if (userCredentials == null) {
throw new UsernameNotFoundException("User credentials not found");
}
if (!userCredentials.isEnabled()) {
if (!userAuthDetails.credentialsEnabled()) {
throw new DisabledException("User is not active");
}
User user = userAuthDetails.user();
if (user.getAuthority() == null) {
throw new InsufficientAuthenticationException("User has no authority assigned");
}
UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
return new SecurityUser(user, true, userPrincipal);
}
}

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

@ -17,7 +17,7 @@ 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 org.thingsboard.server.service.security.model.token.RawApiKey;
import java.io.Serial;
@ -26,12 +26,12 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 2978710889397403536L;
private RawApiKeyToken rawApiKeyToken;
private RawApiKey rawApiKey;
private SecurityUser securityUser;
public ApiKeyAuthenticationToken(RawApiKeyToken raw) {
public ApiKeyAuthenticationToken(RawApiKey rawApiKey) {
super(null);
this.rawApiKeyToken = raw;
this.rawApiKey = rawApiKey;
setAuthenticated(false);
}
@ -44,7 +44,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
@Override
public Object getCredentials() {
return rawApiKeyToken;
return rawApiKey;
}
@Override
@ -55,7 +55,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.rawApiKeyToken = null;
this.rawApiKey = null;
}
}

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

@ -29,13 +29,13 @@ 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.extractor.TokenExtractor;
import org.thingsboard.server.service.security.model.token.RawApiKeyToken;
import org.thingsboard.server.service.security.model.token.RawApiKey;
import java.io.IOException;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER_V2;
public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
@ -52,8 +52,8 @@ public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthentic
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
RawApiKeyToken token = new RawApiKeyToken(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(token));
RawApiKey rawApiKey = new RawApiKey(tokenExtractor.extract(request));
return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(rawApiKey));
}
@Override
@ -70,9 +70,9 @@ public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthentic
if (!super.requiresAuthentication(request, response)) {
return false;
}
String header = request.getHeader(JWT_TOKEN_HEADER_PARAM);
String header = request.getHeader(AUTHORIZATION_HEADER);
if (header == null) {
header = request.getHeader(JWT_TOKEN_HEADER_PARAM_V2);
header = request.getHeader(AUTHORIZATION_HEADER_V2);
}
return header != null && header.startsWith(API_KEY_HEADER_PREFIX);
}

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

@ -82,11 +82,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
Assert.notNull(authentication, "No authentication data provided");
Object principal = authentication.getPrincipal();
if (!(principal instanceof UserPrincipal)) {
if (!(principal instanceof UserPrincipal userPrincipal)) {
throw new BadCredentialsException("Authentication Failed. Bad user principal.");
}
UserPrincipal userPrincipal = (UserPrincipal) principal;
SecurityUser securityUser;
if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) {
String username = userPrincipal.getValue();

4
application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKeyToken.java → application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java

@ -15,6 +15,4 @@
*/
package org.thingsboard.server.service.security.model.token;
public record RawApiKeyToken(String token) {
}
public record RawApiKey(String apiKey) {}

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

@ -16,7 +16,6 @@
package org.thingsboard.server.service.ttl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@ -24,8 +23,6 @@ import org.thingsboard.server.dao.pat.ApiKeyDao;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@TbCoreComponent
@ -35,9 +32,6 @@ public class ApiKeysCleanUpService extends AbstractCleanUpService {
public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION =
"#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.api_keys.checking_interval_ms})}";
@Value("${sql.ttl.api_keys.ttl:2592000}")
private long ttl;
private final ApiKeyDao apiKeyDao;
public ApiKeysCleanUpService(PartitionService partitionService, ApiKeyDao apiKeyDao) {
@ -50,7 +44,7 @@ public class ApiKeysCleanUpService extends AbstractCleanUpService {
fixedDelayString = "${sql.ttl.api_keys.checking_interval_ms:86400000}"
)
public void cleanUp() {
long threshold = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttl);
long threshold = System.currentTimeMillis();
if (isSystemTenantPartitionMine()) {
int deleted = apiKeyDao.deleteAllByExpirationTimeBefore(threshold);
if (deleted > 0) {

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

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

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

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

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

@ -151,6 +151,12 @@ security:
tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Base64 encoded
# Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator
user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}"
# API key parameters
api_key:
# Prefix for the auto-generated API key. For example, tb_Ood4dQMxWvMH-76z3E_Cv0mZaBWT0Clk3hRSO0P_jNQ
value_prefix: "${SECURITY_API_KEY_VALUE_PREFIX:tb_}"
# Length of the auto-generated API key. Max is 255
value_bytes_size: "${SECURITY_API_KEY_VALUE_PREFIX:64}"
# Enable/disable case-sensitive username login
user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}"
claim:
@ -429,7 +435,6 @@ sql:
checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
api_keys:
enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for api keys records
ttl: "${SQL_TTL_API_KEYS_SECS:2592000}" # Default value - 30 days
checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
relations:
max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible
@ -666,6 +671,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:
@ -683,6 +691,9 @@ cache:
maxSize: "${CACHE_SPECS_IMAGE_ETAGS_MAX_SIZE:10000}" # 0 means the cache is disabled
systemImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_SYSTEM_BROWSER_TTL:0}" # Browser cache TTL for system images in minutes. 0 means the cache is disabled
tenantImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_TENANT_BROWSER_TTL:0}" # Browser cache TTL for tenant images in minutes. 0 means the cache is disabled
userEnabled:
timeToLiveInMinutes: "${CACHE_SPECS_USER_ENABLED_TTL:120}" # User enabled cache TTL
maxSize: "${CACHE_SPECS_USER_ENABLED_MAX_SIZE:200000}" # 0 means the cache is disabled
# Spring data parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required.

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

@ -192,6 +192,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX;
import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX;
@Slf4j
public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
@ -237,6 +239,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected String mobileToken;
protected String username;
protected String apiKey;
protected TenantId tenantId;
protected TenantProfileId tenantProfileId;
protected UserId tenantAdminUserId;
@ -631,13 +635,27 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void setJwtToken(MockHttpServletRequestBuilder request) {
if (this.token != null) {
request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token);
request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, BEARER_HEADER_PREFIX + this.token);
}
if (this.mobileToken != null) {
request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken);
}
}
protected void resetApiKey() {
this.apiKey = null;
}
protected void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
protected void setApiKey(MockHttpServletRequestBuilder request) {
if (this.apiKey != null) {
request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, API_KEY_HEADER_PREFIX + this.apiKey);
}
}
protected DeviceProfile createDeviceProfile(String name) {
return createDeviceProfile(name, null);
}
@ -744,6 +762,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(getRequest);
}
protected ResultActions doGetWithApiKey(String urlTemplate, Object... urlVariables) throws Exception {
MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables);
setApiKey(getRequest);
return mockMvc.perform(getRequest);
}
protected <T> T doGet(String urlTemplate, Class<T> responseClass, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass);
}
@ -767,6 +791,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn()));
}
protected <T> T doGetWithApiKey(String urlTemplate, Class<T> responseClass, Object... urlVariables) throws Exception {
return readResponse(doGetWithApiKey(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass);
}
protected <T> T doGetTyped(String urlTemplate, TypeReference<T> responseType, Object... urlVariables) throws Exception {
return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType);
}
@ -846,6 +874,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
}
}
protected <T, R> R doPostWithApiKey(String urlTemplate, T content, Class<R> responseClass, String... params) {
try {
return readResponse(doPostWithApiKey(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
protected <T, R> R doPostWithResponse(String urlTemplate, T content, Class<R> responseClass, String... params) throws Exception {
return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass);
}
@ -913,6 +949,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(postRequest);
}
protected <T> ResultActions doPostWithApiKey(String urlTemplate, T content, String... params) throws Exception {
MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
setApiKey(postRequest);
String json = json(content);
postRequest.contentType(contentType).content(json);
return mockMvc.perform(postRequest);
}
protected <T> ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder postRequest = post(urlTemplate, params);
setJwtToken(postRequest);
@ -930,6 +974,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return mockMvc.perform(deleteRequest);
}
protected ResultActions doDeleteWithApiKey(String urlTemplate, String... params) throws Exception {
MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate);
setApiKey(deleteRequest);
populateParams(deleteRequest, params);
return mockMvc.perform(deleteRequest);
}
protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception {
MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params);
setJwtToken(deleteRequest);
@ -1300,7 +1351,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected List<Job> findJobs(List<JobType> types, List<UUID> entities) throws Exception {
return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) +
"&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&",
"&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&",
new TypeReference<PageData<Job>>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData();
}

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

@ -28,7 +28,6 @@ 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 {
@ -42,8 +41,7 @@ public class ApiKeyControllerTest extends AbstractControllerTest {
public void testSaveApiKey() throws Exception {
ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true);
String apiKeyStr = doPost("/api/apiKey", apiKeyInfo, ApiKey.class).getValue();
Assert.assertTrue(apiKeyStr.startsWith(API_KEY_HEADER_PREFIX));
doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
PageData<ApiKeyInfo> pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0));
Assert.assertEquals(1, pageData.getData().size());

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

@ -30,15 +30,14 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent;
import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent;
import org.thingsboard.server.common.data.security.model.JwtToken;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider;
import org.thingsboard.server.service.security.exception.JwtExpiredTokenException;
@ -46,6 +45,7 @@ import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
import org.thingsboard.server.service.user.cache.UserAuthDetailsCache;
import java.util.UUID;
@ -91,20 +91,16 @@ public class TokenOutdatingTest {
UserId userId = new UserId(UUID.randomUUID());
securityUser = createMockSecurityUser(userId);
UserService userService = mock(UserService.class);
UserAuthDetailsCache userAuthDetailsCache = mock(UserAuthDetailsCache.class);
User user = new User();
user.setId(userId);
user.setAuthority(Authority.TENANT_ADMIN);
user.setEmail("email");
when(userService.findUserById(any(), eq(userId))).thenReturn(user);
UserCredentials userCredentials = new UserCredentials();
userCredentials.setEnabled(true);
when(userService.findUserCredentialsByUserId(any(), eq(userId))).thenReturn(userCredentials);
when(userAuthDetailsCache.findUserEnabled(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true));
accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService);
refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userService, mock(CustomerService.class), tokenOutdatingService);
refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userAuthDetailsCache, mock(CustomerService.class), tokenOutdatingService);
}
@Test

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

@ -15,176 +15,98 @@
*/
package org.thingsboard.server.service.security.auth.pat;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.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.mockito.Mockito;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.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 org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.controller.AbstractControllerTest;
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.dao.model.ModelConstants.NULL_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;
@DaoSqlTest
public class ApiKeyAuthenticationProviderTest extends AbstractControllerTest {
@RunWith(MockitoJUnitRunner.class)
public class ApiKeyAuthenticationProviderTest {
private static final String TEST_API_KEY = "test_api_key";
private static final String USER_EMAIL = "tenant@thingsboard.org";
@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;
ApiKey savedApiKey;
@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.setValue(TEST_API_KEY);
apiKey.setEnabled(true);
apiKey.setExpirationTime(0);
}
public void setUp() throws Exception {
loginTenantAdmin();
@Test
public void testSuccessfulAuthentication() {
when(apiKeyService.findApiKeyByValue(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());
ApiKeyInfo apiKeyInfo = constructApiKeyInfo();
savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
setApiKey(savedApiKey.getValue());
}
@Test(expected = BadCredentialsException.class)
public void testEmptyApiKey() {
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(""));
provider.authenticate(token);
@After
public void cleanUp() throws Exception {
resetApiKey();
doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk());
}
@Test(expected = BadCredentialsException.class)
public void testNonExistentApiKey() {
when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(null);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
provider.authenticate(token);
}
@Test
public void testSaveEdgeWithApiKey() throws Exception {
Edge edge = constructEdge("My edge", "default");
@Test(expected = DisabledException.class)
public void testDisabledApiKey() {
apiKey.setEnabled(false);
when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
Mockito.reset(tbClusterService, auditLogService);
provider.authenticate(token);
}
Edge savedEdge = doPostWithApiKey("/api/edge", edge, Edge.class);
@Test(expected = CredentialsExpiredException.class)
public void testExpiredApiKey() {
apiKey.setExpirationTime(System.currentTimeMillis() - 10000); // Expired 10 seconds ago
when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
Assert.assertNotNull(savedEdge);
Assert.assertNotNull(savedEdge.getId());
Assert.assertTrue(savedEdge.getCreatedTime() > 0);
Assert.assertEquals(tenantId, savedEdge.getTenantId());
Assert.assertNotNull(savedEdge.getCustomerId());
Assert.assertEquals(NULL_UUID, savedEdge.getCustomerId().getId());
Assert.assertEquals(edge.getName(), savedEdge.getName());
provider.authenticate(token);
}
testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(savedEdge, savedEdge.getId(), savedEdge.getId(),
tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.ADDED, 2);
@Test(expected = UsernameNotFoundException.class)
public void testNonExistentUser() {
when(apiKeyService.findApiKeyByValue(TEST_API_KEY)).thenReturn(apiKey);
when(userService.findUserById(tenantId, userId)).thenReturn(null);
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(new RawApiKeyToken(TEST_API_KEY));
savedEdge.setName("My new edge");
doPostWithApiKey("/api/edge", savedEdge, Edge.class);
provider.authenticate(token);
}
Edge foundEdge = doGetWithApiKey("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class);
Assert.assertEquals(foundEdge.getName(), savedEdge.getName());
@Test(expected = UsernameNotFoundException.class)
public void testNonExistentUserCredentials() {
when(apiKeyService.findApiKeyByValue(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));
testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(foundEdge, foundEdge.getId(), foundEdge.getId(),
tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(),
ActionType.UPDATED, 1);
provider.authenticate(token);
doDeleteWithApiKey("/api/edge/" + savedEdge.getId().getId().toString())
.andExpect(status().isOk());
}
@Test(expected = DisabledException.class)
public void testDisabledUser() {
userCredentials.setEnabled(false);
when(apiKeyService.findApiKeyByValue(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 testUnauthorizedWhenKeyDisabled() throws Exception {
ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class);
Assert.assertFalse(disabledApiKeyInfo.isEnabled());
doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized());
}
@Test(expected = InsufficientAuthenticationException.class)
public void testUserWithoutAuthority() {
user.setAuthority(null);
when(apiKeyService.findApiKeyByValue(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 testUnauthorizedWhenKeyExpired() throws Exception {
ApiKeyInfo apiKeyInfo = constructApiKeyInfo();
apiKeyInfo.setExpirationTime(System.currentTimeMillis() - 1000);
ApiKey savedApiKeyWithBad = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
setApiKey(savedApiKeyWithBad.getValue());
doPost("/api/apiKey", savedApiKey, ApiKeyInfo.class);
doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized());
}
@Test
public void testSupports() {
assertTrue(provider.supports(ApiKeyAuthenticationToken.class));
private ApiKeyInfo constructApiKeyInfo() {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription("New API key description");
apiKeyInfo.setEnabled(true);
apiKeyInfo.setUserId(tenantAdminUserId);
return apiKeyInfo;
}
}

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

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.user;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
@ -109,4 +110,6 @@ public interface UserService extends EntityDaoService {
void removeMobileSession(TenantId tenantId, String mobileToken);
UserAuthDetails findUserAuthDetailsByUserId(TenantId tenantId, UserId userId);
}

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

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

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

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

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

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

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

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

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

@ -0,0 +1,40 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.pat;
import org.checkerframework.checker.nullness.qual.NonNull;
import java.io.Serializable;
import static java.util.Objects.requireNonNull;
record ApiKeyCacheKey(String value) implements Serializable {
ApiKeyCacheKey {
requireNonNull(value);
}
static ApiKeyCacheKey of(String value) {
return new ApiKeyCacheKey(value);
}
@NonNull
@Override
public String toString() {
return /* cache name */ "_" + value;
}
}

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

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.pat;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.thingsboard.server.cache.CaffeineTbTransactionalCache;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.pat.ApiKey;
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
@Service("ApiKeyCache")
public class ApiKeyCaffeineCache extends CaffeineTbTransactionalCache<ApiKeyCacheKey, ApiKey> {
public ApiKeyCaffeineCache(CacheManager cacheManager) {
super(cacheManager, CacheConstants.API_KEYS_CACHE);
}
}

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

@ -20,13 +20,15 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.dao.Dao;
import java.util.Set;
public interface ApiKeyDao extends Dao<ApiKey> {
ApiKey findByValue(String value);
void deleteByTenantId(TenantId tenantId);
Set<String> deleteByTenantId(TenantId tenantId);
void deleteByUserId(TenantId tenantId, UserId userId);
Set<String> deleteByUserId(TenantId tenantId, UserId userId);
int deleteAllByExpirationTimeBefore(long ts);

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

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

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

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

@ -17,7 +17,10 @@ package org.thingsboard.server.dao.pat;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.ApiKeyId;
@ -29,10 +32,12 @@ 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.AbstractEntityService;
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;
@ -42,15 +47,28 @@ import static org.thingsboard.server.dao.user.UserServiceImpl.INCORRECT_USER_ID;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeyService {
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 static final int MAX_API_KEY_VALUE_LENGTH = 255;
private final ApiKeyDao apiKeyDao;
private final ApiKeyInfoDao apiKeyInfoDao;
@Lazy
private final ApiKeyDataValidator apiKeyValidator;
@Value("${security.api_key.value_prefix:}")
private String prefix;
@Value("${security.api_key.value_bytes_size:}")
private int valueBytesSize;
@Override
@TransactionalEventListener
public void handleEvictEvent(ApiKeyEvictEvent event) {
cache.evict(ApiKeyCacheKey.of(event.value()));
}
@Override
public ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKeyInfo) {
log.trace("Executing saveApiKey [{}]", apiKeyInfo);
@ -58,14 +76,19 @@ public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeySe
var apiKey = new ApiKey(apiKeyInfo);
var old = apiKeyValidator.validate(apiKey, ApiKeyInfo::getTenantId);
if (old == null) {
String hash = generateApiKeySecret();
apiKey.setValue(hash);
String value = generateApiKeySecret();
apiKey.setValue(value);
} else {
apiKey.setValue(old.getValue());
}
return apiKeyDao.save(tenantId, apiKey);
var savedApiKey = apiKeyDao.save(tenantId, apiKey);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entityId(savedApiKey.getId()).entity(savedApiKey).created(apiKey.getId() == null).build());
if (old != null && old.isEnabled() != apiKey.isEnabled()) {
publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue()));
}
return savedApiKey;
} catch (Exception e) {
checkConstraintViolation(e, "api_hash_unq_key", "Api Key with such hash already exists!");
checkConstraintViolation(e, "api_key_value_unq_key", "Api Key with such value already exists!");
throw e;
}
}
@ -91,46 +114,37 @@ public class ApiKeyServiceImpl extends AbstractEntityService implements ApiKeySe
@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();
UUID apiKeyId = apiKey.getUuidId();
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.getValue()));
}
@Override
public void deleteByTenantId(TenantId tenantId) {
log.trace("Executing deleteApiKeysByTenantId, tenantId [{}]", tenantId);
validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
apiKeyDao.deleteByTenantId(tenantId);
Set<String> values = apiKeyDao.deleteByTenantId(tenantId);
values.forEach(value -> publishEvictEvent(new ApiKeyEvictEvent(value)));
}
@Override
public void deleteByUserId(TenantId tenantId, UserId userId) {
log.trace("Executing deleteApiKeysByUserId, tenantId [{}]", tenantId);
validateId(userId, id -> INCORRECT_USER_ID + id);
apiKeyDao.deleteByUserId(tenantId, userId);
Set<String> values = apiKeyDao.deleteByUserId(tenantId, userId);
values.forEach(value -> publishEvictEvent(new ApiKeyEvictEvent(value)));
}
@Override
public ApiKey findApiKeyByValue(String value) {
log.trace("Executing findApiKeyByValue [{}]", value);
return apiKeyDao.findByValue(value);
var cacheKey = ApiKeyCacheKey.of(value);
return cache.getAndPutInTransaction(cacheKey, () -> apiKeyDao.findByValue(value), true);
}
private static String generateApiKeySecret() {
return StringUtils.generateSafeToken(DEFAULT_API_KEY_BYTES);
private String generateApiKeySecret() {
return prefix + StringUtils.generateSafeToken(Math.min(valueBytesSize, MAX_API_KEY_VALUE_LENGTH));
}
@Override

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

@ -22,22 +22,22 @@ 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;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.user.UserService;
@Component
@RequiredArgsConstructor
public class ApiKeyDataValidator extends DataValidator<ApiKey> {
private final ApiKeyDao apiKeyDao;
private final TenantDao tenantDao;
private final UserDao userDao;
private final TenantService tenantService;
private final UserService userService;
@Override
protected void validateDataImpl(TenantId tenantId, ApiKey apiKey) {
if (apiKey.getId() != null) {
if (apiKey.getUuidId() == null) {
throw new DataValidationException("Api Key UUID should be specified!");
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!");
@ -47,14 +47,14 @@ public class ApiKeyDataValidator extends DataValidator<ApiKey> {
if (apiKey.getTenantId() == null || apiKey.getTenantId().getId() == null) {
throw new DataValidationException("API key should be assigned to tenant!");
}
if (!TenantId.SYS_TENANT_ID.equals(apiKey.getTenantId()) && tenantDao.findById(apiKey.getTenantId(), apiKey.getTenantId().getId()) == null) {
if (!TenantId.SYS_TENANT_ID.equals(apiKey.getTenantId()) && !tenantService.tenantExists(apiKey.getTenantId())) {
throw new DataValidationException("API key reference a non-existent tenant!");
}
if (apiKey.getUserId() == null || apiKey.getUserId().getId() == null) {
throw new DataValidationException("API key should be assigned to user!");
}
if (userDao.findById(apiKey.getTenantId(), apiKey.getUserId().getId()) == null) {
if (userService.findUserById(apiKey.getTenantId(), apiKey.getUserId()) == null) {
throw new DataValidationException("API key reference a non-existent user!");
}
}
@ -66,10 +66,10 @@ public class ApiKeyDataValidator extends DataValidator<ApiKey> {
throw new DataValidationException("Cannot update non-existent API key!");
}
if (!old.getUserId().equals(apiKey.getUserId())) {
throw new DataValidationException("Cannot update api key user id!");
throw new DataValidationException("Cannot update API key user id!");
}
if (old.getExpirationTime() != apiKey.getExpirationTime()) {
throw new DataValidationException("Cannot update api key expiration time!");
throw new DataValidationException("Cannot update API key expiration time!");
}
return old;
}

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

@ -22,6 +22,7 @@ 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> {
@ -30,14 +31,24 @@ public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {
@Transactional
@Modifying
@Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId")
void deleteByTenantId(@Param("tenantId") UUID tenantId);
@Query(value = """
DELETE FROM api_key
WHERE tenant_id = :tenantId
RETURNING value
""", nativeQuery = true
)
Set<String> deleteByTenantId(@Param("tenantId") UUID tenantId);
@Transactional
@Modifying
@Query("DELETE FROM ApiKeyEntity ak WHERE ak.tenantId = :tenantId AND ak.userId = :userId")
void deleteByUserId(@Param("tenantId") UUID tenantId,
@Param("userId") UUID userId);
@Query(value = """
DELETE FROM api_key
WHERE tenant_id = :tenantId AND user_id = :userId
RETURNING value
""", nativeQuery = true
)
Set<String> deleteByUserId(@Param("tenantId") UUID tenantId,
@Param("userId") UUID userId);
@Transactional
@Modifying

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

@ -46,13 +46,13 @@ public class JpaApiKeyDao extends JpaAbstractDao<ApiKeyEntity, ApiKey> implement
}
@Override
public void deleteByTenantId(TenantId tenantId) {
apiKeyRepository.deleteByTenantId(tenantId.getId());
public Set<String> deleteByTenantId(TenantId tenantId) {
return apiKeyRepository.deleteByTenantId(tenantId.getId());
}
@Override
public void deleteByUserId(TenantId tenantId, UserId userId) {
apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId());
public Set<String> deleteByUserId(TenantId tenantId, UserId userId) {
return apiKeyRepository.deleteByUserId(tenantId.getId(), userId.getId());
}
@Override

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

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

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

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

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

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

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

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

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

@ -34,6 +34,7 @@ import org.thingsboard.server.cache.user.UserCacheKey;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserAuthDetails;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
@ -296,7 +297,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
@Override
public UserCredentials checkUserActivationToken(TenantId tenantId, UserCredentials userCredentials) {
if (userCredentials.getActivationTokenTtl() < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration
if (userCredentials.getActivationTokenTtl() < TimeUnit.MINUTES.toMillis(15)) { // renew a link if less than 15 minutes before expiration
userCredentials = generateUserActivationToken(userCredentials);
userCredentials = saveUserCredentials(tenantId, userCredentials);
log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userCredentials.getUserId());
@ -488,6 +489,13 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
}
}
@Override
public UserAuthDetails findUserAuthDetailsByUserId(TenantId tenantId, UserId userId) {
log.trace("Executing findUserEnabled [{}]", userId);
validateId(userId, id -> INCORRECT_USER_ID + id);
return userDao.findUserAuthDetailsByUserId(tenantId.getId(), userId.getId());
}
private Optional<UserMobileSessionInfo> findMobileSessionInfo(TenantId tenantId, UserId userId) {
return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE))
.map(UserSettings::getSettings).map(settings -> JacksonUtil.treeToValue(settings, UserMobileSessionInfo.class));

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

@ -714,11 +714,11 @@ CREATE TABLE IF NOT EXISTS api_key (
created_time bigint NOT NULL,
tenant_id uuid,
user_id uuid,
value varchar(255),
value varchar(512),
enabled boolean NOT NULL DEFAULT TRUE,
expiration_time bigint DEFAULT 0,
description varchar(1024),
CONSTRAINT api_value_unq_key UNIQUE (value)
CONSTRAINT api_key_value_unq_key UNIQUE (value)
);
CREATE TABLE IF NOT EXISTS resource (

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

@ -226,7 +226,7 @@ public class RestClient implements Closeable {
private long refreshTokenExpTs;
private long clientServerTimeDiff;
public enum AuthType { JWT, API_KEY }
public enum AuthType {JWT, API_KEY}
public RestClient(String baseURL) {
this(new RestTemplate(), baseURL);
@ -236,6 +236,10 @@ public class RestClient implements Closeable {
this(restTemplate, baseURL, AuthType.JWT, null);
}
public RestClient(RestTemplate restTemplate, String baseURL, String accessToken) {
this(restTemplate, baseURL, AuthType.JWT, accessToken);
}
public RestClient(RestTemplate restTemplate, String baseURL, AuthType authType, String token) {
this.restTemplate = restTemplate;
this.loginRestTemplate = new RestTemplate(restTemplate.getRequestFactory());
@ -3012,7 +3016,7 @@ public class RestClient implements Closeable {
addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params);
return restTemplate.exchange(
baseURL + "/api/widgetTypes?" + getUrlParams(pageLink) +
getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList),
getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList),
HttpMethod.GET,
HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<WidgetTypeInfo>>() {
@ -3100,7 +3104,7 @@ public class RestClient implements Closeable {
addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params);
return restTemplate.exchange(
baseURL + "/api/widgetTypesInfos?widgetsBundleId={widgetsBundleId}&" + getUrlParams(pageLink) +
getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList),
getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList),
HttpMethod.GET,
HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<WidgetTypeInfo>>() {

Loading…
Cancel
Save