diff --git a/application/src/main/data/json/system/widget_types/asset_admin_table.json b/application/src/main/data/json/system/widget_types/asset_admin_table.json index 0a2eb2c37a..fe5722a01a 100644 --- a/application/src/main/data/json/system/widget_types/asset_admin_table.json +++ b/application/src/main/data/json/system/widget_types/asset_admin_table.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "tb-entities-table-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/device_admin_table.json b/application/src/main/data/json/system/widget_types/device_admin_table.json index af460a286c..35cb457590 100644 --- a/application/src/main/data/json/system/widget_types/device_admin_table.json +++ b/application/src/main/data/json/system/widget_types/device_admin_table.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "tb-entities-table-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/entities_hierarchy.json b/application/src/main/data/json/system/widget_types/entities_hierarchy.json index 48e9c3e632..5dc2788f12 100644 --- a/application/src/main/data/json/system/widget_types/entities_hierarchy.json +++ b/application/src/main/data/json/system/widget_types/entities_hierarchy.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "tb-entities-hierarchy-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/entities_table.json b/application/src/main/data/json/system/widget_types/entities_table.json index 1ebf8d4fde..73a546cb87 100644 --- a/application/src/main/data/json/system/widget_types/entities_table.json +++ b/application/src/main/data/json/system/widget_types/entities_table.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'name', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'name', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "tb-entities-table-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/timeseries_table.json b/application/src/main/data/json/system/widget_types/timeseries_table.json index d267d42763..767f6e8c33 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_table.json +++ b/application/src/main/data/json/system/widget_types/timeseries_table.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onLatestDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.timeseriesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "", "latestDataKeySettingsSchema": "", diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 9538555b2c..33e2ddee01 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -136,6 +136,7 @@ public class ThingsboardInstallService { dataUpdateService.updateData("3.6.4"); entityDatabaseSchemaService.createCustomerTitleUniqueConstraintIfNotExists(); systemDataLoaderService.updateDefaultNotificationConfigs(false); + systemDataLoaderService.updateJwtSettings(); //TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache break; default: diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 8c86c16edb..bdd0494443 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -19,13 +19,17 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -81,6 +85,7 @@ import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; @@ -100,14 +105,11 @@ import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.dao.widget.WidgetTypeService; -import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.TreeMap; @@ -117,79 +119,51 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.server.common.data.DataConstants.DEFAULT_DEVICE_TYPE; +import static org.thingsboard.server.service.security.auth.jwt.settings.DefaultJwtSettingsService.isSigningKeyDefault; +import static org.thingsboard.server.service.security.auth.jwt.settings.DefaultJwtSettingsService.validateTokenSigningKeyLength; @Service @Profile("install") @Slf4j +@RequiredArgsConstructor public class DefaultSystemDataLoaderService implements SystemDataLoaderService { public static final String CUSTOMER_CRED = "customer"; public static final String ACTIVITY_STATE = "active"; - @Autowired - private InstallScripts installScripts; + private final InstallScripts installScripts; + private final UserService userService; + private final AdminSettingsService adminSettingsService; + private final TenantService tenantService; + private final TenantProfileService tenantProfileService; + private final CustomerService customerService; + private final DeviceService deviceService; + private final DeviceProfileService deviceProfileService; + private final AttributesService attributesService; + private final DeviceCredentialsService deviceCredentialsService; + private final RuleChainService ruleChainService; + private final TimeseriesService tsService; + private final DeviceConnectivityConfiguration connectivityConfiguration; + private final QueueService queueService; + private final JwtSettingsService jwtSettingsService; + private final NotificationSettingsService notificationSettingsService; + private final NotificationTargetService notificationTargetService; @Autowired private BCryptPasswordEncoder passwordEncoder; - @Autowired - private UserService userService; - - @Autowired - private AdminSettingsService adminSettingsService; - - @Autowired - private WidgetTypeService widgetTypeService; - - @Autowired - private WidgetsBundleService widgetsBundleService; - - @Autowired - private TenantService tenantService; - - @Autowired - private TenantProfileService tenantProfileService; - - @Autowired - private CustomerService customerService; - - @Autowired - private DeviceService deviceService; - - @Autowired - private DeviceProfileService deviceProfileService; - - @Autowired - private AttributesService attributesService; - - @Autowired - private DeviceCredentialsService deviceCredentialsService; - - @Autowired - private RuleChainService ruleChainService; - - @Autowired - private TimeseriesService tsService; - - @Autowired - private DeviceConnectivityConfiguration connectivityConfiguration; - @Value("${state.persistToTelemetry:false}") @Getter private boolean persistActivityToTelemetry; - @Lazy - @Autowired - private QueueService queueService; - - @Autowired - private JwtSettingsService jwtSettingsService; - - @Autowired - private NotificationSettingsService notificationSettingsService; - - @Autowired - private NotificationTargetService notificationTargetService; + @Value("${security.jwt.tokenExpirationTime:9000}") + private Integer tokenExpirationTime; + @Value("${security.jwt.refreshTokenExpTime:604800}") + private Integer refreshTokenExpTime; + @Value("${security.jwt.tokenIssuer:thingsboard.io}") + private String tokenIssuer; + @Value("${security.jwt.tokenSigningKey:thingsboardDefaultSigningKey}") + private String tokenSigningKey; @Bean protected BCryptPasswordEncoder passwordEncoder() { @@ -295,7 +269,42 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { @Override public void createRandomJwtSettings() throws Exception { - jwtSettingsService.createRandomJwtSettings(); + if (jwtSettingsService.getJwtSettings() == null) { + log.info("Creating JWT admin settings..."); + var jwtSettings = new JwtSettings(this.tokenExpirationTime, this.refreshTokenExpTime, this.tokenIssuer, this.tokenSigningKey); + if (isSigningKeyDefault(jwtSettings) || !validateTokenSigningKeyLength(jwtSettings)) { + jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString( + RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); + } + jwtSettingsService.saveJwtSettings(jwtSettings); + } else { + log.info("Skip creating JWT admin settings because they already exist."); + } + } + + @Override + public void updateJwtSettings() { + JwtSettings jwtSettings = jwtSettingsService.getJwtSettings(); + boolean invalidSignKey = false; + String warningMessage = null; + + if (isSigningKeyDefault(jwtSettings)) { + warningMessage = "The platform is using the default JWT Signing Key, which is a security risk."; + invalidSignKey = true; + } else if (!validateTokenSigningKeyLength(jwtSettings)) { + warningMessage = "The JWT Signing Key is shorter than 512 bits, which is a security risk."; + invalidSignKey = true; + } + + if (invalidSignKey) { + log.warn("WARNING: {}. A new JWT Signing Key has been added automatically. " + + "You can change the JWT Signing Key using the Web UI: " + + "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator.", warningMessage); + + jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString( + RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); + jwtSettingsService.saveJwtSettings(jwtSettings); + } } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java index 9058db4172..3ba938bd90 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java @@ -84,13 +84,17 @@ public abstract class SqlAbstractDatabaseSchemaService implements DatabaseSchema } protected void executeQuery(String query) { + executeQuery(query, null); + } + + protected void executeQuery(String query, String logQuery) { + logQuery = logQuery != null ? logQuery : query; try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { conn.createStatement().execute(query); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script - log.info("Successfully executed query: {}", query); + log.info("Successfully executed query: {}", logQuery); Thread.sleep(5000); } catch (InterruptedException | SQLException e) { - log.error("Failed to execute query: {} due to: {}", query, e.getMessage()); - throw new RuntimeException("Failed to execute query: " + query, e); + throw new RuntimeException("Failed to execute query: " + logQuery, e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java index b1a41aadcd..5e1357a48e 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java @@ -56,6 +56,7 @@ public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaSer @Override public void createCustomerTitleUniqueConstraintIfNotExists() { executeQuery("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'customer_title_unq_key') THEN " + - "ALTER TABLE customer ADD CONSTRAINT customer_title_unq_key UNIQUE(tenant_id, title); END IF; END; $$;"); + "ALTER TABLE customer ADD CONSTRAINT customer_title_unq_key UNIQUE(tenant_id, title); END IF; END; $$;", + "create 'customer_title_unq_key' constraint if it doesn't already exist!"); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java index 7c05ff620f..71c829ee11 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java @@ -25,6 +25,8 @@ public interface SystemDataLoaderService { void createRandomJwtSettings() throws Exception; + void updateJwtSettings() throws Exception; + void createOAuth2Templates() throws Exception; void loadSystemWidgets() throws Exception; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 29db1d7d76..257dfa14b1 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -16,12 +16,13 @@ package org.thingsboard.server.service.mail; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; import freemarker.template.Configuration; import freemarker.template.Template; -import jakarta.xml.bind.DatatypeConverter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Lazy; import org.springframework.core.NestedRuntimeException; @@ -31,29 +32,36 @@ import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import org.springframework.ui.freemarker.FreeMarkerTemplateUtils; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.TbEmail; +import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiFeature; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageRecordState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.RateLimitExceededException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.limit.LimitedApi; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.mail.internet.MimeMessage; import java.io.ByteArrayInputStream; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -76,13 +84,21 @@ public class DefaultMailService implements MailService { private TbApiUsageStateService apiUsageStateService; @Autowired - private MailExecutorService mailExecutorService; + private MailSenderInternalExecutorService mailExecutorService; @Autowired private PasswordResetExecutorService passwordResetExecutorService; @Autowired - private TbMailContextComponent tbMailContextComponent; + private TbMailContextComponent ctx; + + @Autowired + private RateLimitService rateLimitService; + + @Value("${mail.per_tenant_rate_limits:}") + private String perTenantRateLimitConfig; + + private final ScheduledExecutorService timeoutScheduler; private TbMailSender mailSender; @@ -95,6 +111,7 @@ public class DefaultMailService implements MailService { this.freemarkerConfig = freemarkerConfig; this.adminSettingsService = adminSettingsService; this.apiUsageClient = apiUsageClient; + this.timeoutScheduler = Executors.newScheduledThreadPool(1, ThingsBoardThreadFactory.forName("mail-service-watchdog")); } @PostConstruct @@ -102,12 +119,19 @@ public class DefaultMailService implements MailService { updateMailConfiguration(); } + @PreDestroy + public void destroy() { + if (timeoutScheduler != null) { + timeoutScheduler.shutdownNow(); + } + } + @Override public void updateMailConfiguration() { AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); if (settings != null) { JsonNode jsonConfig = settings.getJsonValue(); - mailSender = new TbMailSender(tbMailContextComponent, jsonConfig); + mailSender = new TbMailSender(ctx, jsonConfig); mailFrom = jsonConfig.get("mailFrom").asText(); timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); } else { @@ -122,7 +146,7 @@ public class DefaultMailService implements MailService { @Override public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException { - TbMailSender testMailSender = new TbMailSender(tbMailContextComponent, jsonConfig); + TbMailSender testMailSender = new TbMailSender(ctx, jsonConfig); String mailFrom = jsonConfig.get("mailFrom").asText(); String subject = messages.getMessage("test.message.subject", null, Locale.US); long timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); @@ -214,6 +238,10 @@ public class DefaultMailService implements MailService { private void sendMail(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) { + if (tenantId != null && !tenantId.isSysTenantId() && StringUtils.isNotEmpty(perTenantRateLimitConfig) && + !rateLimitService.checkRateLimit(LimitedApi.EMAILS, (Object) tenantId, perTenantRateLimitConfig)) { + throw new RateLimitExceededException(LimitedApi.EMAILS); + } try { MimeMessage mailMsg = javaMailSender.createMimeMessage(); boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty()); @@ -415,8 +443,11 @@ public class DefaultMailService implements MailService { } private void sendMailWithTimeout(JavaMailSender mailSender, MimeMessage msg, long timeout) { + var submittedMail = Futures.withTimeout( + mailExecutorService.submit(() -> mailSender.send(msg)), + timeout, TimeUnit.MILLISECONDS, timeoutScheduler); try { - mailExecutorService.submit(() -> mailSender.send(msg)).get(timeout, TimeUnit.MILLISECONDS); + submittedMail.get(timeout, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { log.debug("Error during mail submission", e); throw new RuntimeException("Timeout!"); diff --git a/application/src/main/java/org/thingsboard/server/service/mail/MailSenderInternalExecutorService.java b/application/src/main/java/org/thingsboard/server/service/mail/MailSenderInternalExecutorService.java new file mode 100644 index 0000000000..0c8feff6c5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/MailSenderInternalExecutorService.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.mail; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +/** + * Executor have the sole purpose to send mails. It should be used only by Mail Service. + * For other purposes please use the MailExecutorService component + * */ +@Component +public class MailSenderInternalExecutorService extends AbstractListeningExecutor { + + @Value("${actors.rule.mail_thread_pool_size}") + private int mailExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return mailExecutorThreadPoolSize; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java index 4df152936f..97c20fdcda 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java @@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.thingsboard.server.dao.settings.AdminSettingsService; -import org.thingsboard.server.queue.util.TbCoreComponent; @Component @Data diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index cdb3f19601..42c516a8a3 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -175,7 +175,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService tbClusterService; - private final Optional notificationCenter; private final JwtSettingsValidator jwtSettingsValidator; - - @Value("${security.jwt.tokenExpirationTime:9000}") - private Integer tokenExpirationTime; - @Value("${security.jwt.refreshTokenExpTime:604800}") - private Integer refreshTokenExpTime; - @Value("${security.jwt.tokenIssuer:thingsboard.io}") - private String tokenIssuer; - @Value("${security.jwt.tokenSigningKey:thingsboardDefaultSigningKey}") - private String tokenSigningKey; + private final Optional jwtTokenFactory; private volatile JwtSettings jwtSettings = null; //lazy init - /** - * Create JWT admin settings is intended to be called from Install scripts only - */ - @Override - public void createRandomJwtSettings() { - if (getJwtSettingsFromDb() == null) { - log.info("Creating JWT admin settings..."); - this.jwtSettings = getJwtSettingsFromYml(); - if (isSigningKeyDefault(jwtSettings)) { - this.jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString( - RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); - } - saveJwtSettings(jwtSettings); - } else { - log.info("Skip creating JWT admin settings because they already exist."); - } - } - - /** - * Create JWT admin settings is intended to be called from Upgrade scripts only - */ - @Override - public void saveLegacyYmlSettings() { - log.info("Saving legacy JWT admin settings from YML..."); - if (getJwtSettingsFromDb() == null) { - saveJwtSettings(getJwtSettingsFromYml()); - } - } - @Override public JwtSettings saveJwtSettings(JwtSettings jwtSettings) { jwtSettingsValidator.validate(jwtSettings); @@ -105,7 +66,9 @@ public class DefaultJwtSettingsService implements JwtSettingsService { @Override public JwtSettings reloadJwtSettings() { log.trace("Executing reloadJwtSettings"); - return getJwtSettings(true); + var settings = getJwtSettings(true); + jwtTokenFactory.ifPresent(JwtTokenFactory::reload); + return settings; } @Override @@ -118,30 +81,13 @@ public class DefaultJwtSettingsService implements JwtSettingsService { if (this.jwtSettings == null || forceReload) { synchronized (this) { if (this.jwtSettings == null || forceReload) { - JwtSettings result = getJwtSettingsFromDb(); - if (result == null) { - result = getJwtSettingsFromYml(); - log.warn("Loading the JWT settings from YML since there are no settings in DB. Looks like the upgrade script was not applied."); - } - if (isSigningKeyDefault(result)) { - log.warn("WARNING: The platform is configured to use default JWT Signing Key. " + - "This is a security issue that needs to be resolved. Please change the JWT Signing Key using the Web UI. " + - "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator."); - notificationCenter.ifPresent(notificationCenter -> { - notificationCenter.sendGeneralWebNotification(TenantId.SYS_TENANT_ID, new SystemAdministratorsFilter(), DefaultNotifications.jwtSigningKeyIssue.toTemplate()); - }); - } - this.jwtSettings = result; + jwtSettings = getJwtSettingsFromDb(); } } } return this.jwtSettings; } - private JwtSettings getJwtSettingsFromYml() { - return new JwtSettings(this.tokenExpirationTime, this.refreshTokenExpTime, this.tokenIssuer, this.tokenSigningKey); - } - private JwtSettings getJwtSettingsFromDb() { AdminSettings adminJwtSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, ADMIN_SETTINGS_JWT_KEY); return adminJwtSettings != null ? mapAdminToJwtSettings(adminJwtSettings) : null; @@ -161,8 +107,12 @@ public class DefaultJwtSettingsService implements JwtSettingsService { return adminJwtSettings; } - private boolean isSigningKeyDefault(JwtSettings settings) { + public static boolean isSigningKeyDefault(JwtSettings settings) { return TOKEN_SIGNING_KEY_DEFAULT.equals(settings.getTokenSigningKey()); } + public static boolean validateTokenSigningKeyLength(JwtSettings settings) { + return Base64.getDecoder().decode(settings.getTokenSigningKey()).length * Byte.SIZE >= KEY_LENGTH; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java index b807f65d36..ea8c67e3dc 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java @@ -27,6 +27,9 @@ import java.util.Base64; import java.util.Optional; import java.util.concurrent.TimeUnit; +import static org.thingsboard.server.service.security.auth.jwt.settings.DefaultJwtSettingsService.isSigningKeyDefault; +import static org.thingsboard.server.service.security.model.token.JwtTokenFactory.KEY_LENGTH; + @Component @RequiredArgsConstructor public class DefaultJwtSettingsValidator implements JwtSettingsValidator { @@ -59,8 +62,8 @@ public class DefaultJwtSettingsValidator implements JwtSettingsValidator { if (Arrays.isNullOrEmpty(decodedKey)) { throw new DataValidationException("JWT token signing key should be non-empty after Base64 decoding!"); } - if (decodedKey.length * Byte.SIZE < 256 && !JwtSettingsService.TOKEN_SIGNING_KEY_DEFAULT.equals(jwtSettings.getTokenSigningKey())) { - throw new DataValidationException("JWT token signing key should be a Base64 encoded string representing at least 256 bits of data!"); + if (decodedKey.length * Byte.SIZE < KEY_LENGTH && !isSigningKeyDefault(jwtSettings)) { + 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 diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java index 19095ad0b9..d3aa261b00 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java @@ -26,10 +26,6 @@ public interface JwtSettingsService { JwtSettings reloadJwtSettings(); - void createRandomJwtSettings(); - - void saveLegacyYmlSettings(); - JwtSettings saveJwtSettings(JwtSettings jwtSettings); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index 08622f578a..f2e276a0ef 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -16,16 +16,19 @@ package org.thingsboard.server.service.security.model.token; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ClaimsBuilder; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; @@ -41,7 +44,10 @@ import org.thingsboard.server.service.security.exception.JwtExpiredTokenExceptio import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; import java.time.ZonedDateTime; +import java.util.Base64; import java.util.Collections; import java.util.Date; import java.util.List; @@ -53,6 +59,8 @@ import java.util.stream.Collectors; @Slf4j public class JwtTokenFactory { + public static int KEY_LENGTH = Jwts.SIG.HS512.getKeyBitLength(); + private static final String SCOPES = "scopes"; private static final String USER_ID = "userId"; private static final String FIRST_NAME = "firstName"; @@ -63,8 +71,12 @@ public class JwtTokenFactory { private static final String CUSTOMER_ID = "customerId"; private static final String SESSION_ID = "sessionId"; + @Lazy private final JwtSettingsService jwtSettingsService; + private volatile JwtParser jwtParser; + private volatile SecretKey secretKey; + /** * Factory method for issuing new JWT Tokens. */ @@ -95,7 +107,7 @@ public class JwtTokenFactory { public SecurityUser parseAccessJwtToken(String token) { Jws jwsClaims = parseTokenClaims(token); - Claims claims = jwsClaims.getBody(); + Claims claims = jwsClaims.getPayload(); String subject = claims.getSubject(); @SuppressWarnings("unchecked") List scopes = claims.get(SCOPES, List.class); @@ -140,14 +152,14 @@ public class JwtTokenFactory { String token = setUpToken(securityUser, Collections.singletonList(Authority.REFRESH_TOKEN.name()), jwtSettingsService.getJwtSettings().getRefreshTokenExpTime()) .claim(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID) - .setId(UUID.randomUUID().toString()).compact(); + .id(UUID.randomUUID().toString()).compact(); return new AccessJwtToken(token); } public SecurityUser parseRefreshToken(String token) { Jws jwsClaims = parseTokenClaims(token); - Claims claims = jwsClaims.getBody(); + Claims claims = jwsClaims.getPayload(); String subject = claims.getSubject(); @SuppressWarnings("unchecked") List scopes = claims.get(SCOPES, List.class); @@ -176,6 +188,11 @@ public class JwtTokenFactory { return new AccessJwtToken(jwtBuilder.compact()); } + public void reload() { + getSecretKey(true); + getJwtParser(true); + } + private JwtBuilder setUpToken(SecurityUser securityUser, List scopes, long expirationTime) { if (StringUtils.isBlank(securityUser.getEmail())) { throw new IllegalArgumentException("Cannot create JWT Token without username/email"); @@ -183,28 +200,27 @@ public class JwtTokenFactory { UserPrincipal principal = securityUser.getUserPrincipal(); - Claims claims = Jwts.claims().setSubject(principal.getValue()); - claims.put(USER_ID, securityUser.getId().getId().toString()); - claims.put(SCOPES, scopes); + ClaimsBuilder claimsBuilder = Jwts.claims() + .subject(principal.getValue()) + .add(USER_ID, securityUser.getId().getId().toString()) + .add(SCOPES, scopes); if (securityUser.getSessionId() != null) { - claims.put(SESSION_ID, securityUser.getSessionId()); + claimsBuilder.add(SESSION_ID, securityUser.getSessionId()); } ZonedDateTime currentTime = ZonedDateTime.now(); return Jwts.builder() - .setClaims(claims) - .setIssuer(jwtSettingsService.getJwtSettings().getTokenIssuer()) - .setIssuedAt(Date.from(currentTime.toInstant())) - .setExpiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant())) - .signWith(SignatureAlgorithm.HS512, jwtSettingsService.getJwtSettings().getTokenSigningKey()); + .claims(claimsBuilder.build()) + .issuer(jwtSettingsService.getJwtSettings().getTokenIssuer()) + .issuedAt(Date.from(currentTime.toInstant())) + .expiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant())) + .signWith(getSecretKey(false), Jwts.SIG.HS512); } public Jws parseTokenClaims(String token) { try { - return Jwts.parser() - .setSigningKey(jwtSettingsService.getJwtSettings().getTokenSigningKey()) - .parseClaimsJws(token); + return getJwtParser(false).parseSignedClaims(token); } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException ex) { log.debug("Invalid JWT Token", ex); throw new BadCredentialsException("Invalid JWT token: ", ex); @@ -220,4 +236,28 @@ public class JwtTokenFactory { return new JwtPair(accessToken.getToken(), refreshToken.getToken()); } + private SecretKey getSecretKey(boolean forceReload) { + if (secretKey == null || forceReload) { + synchronized (this) { + if (secretKey == null || forceReload) { + byte[] decodedToken = Base64.getDecoder().decode(jwtSettingsService.getJwtSettings().getTokenSigningKey()); + secretKey = new SecretKeySpec(decodedToken, "HmacSHA512"); + } + } + } + return secretKey; + } + + private JwtParser getJwtParser(boolean forceReload) { + if (jwtParser == null || forceReload) { + synchronized (this) { + if (jwtParser == null || forceReload) { + jwtParser = Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(jwtSettingsService.getJwtSettings().getTokenSigningKey()))) + .build(); + } + } + } + return jwtParser; + } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java index 7f956f6970..cf01dec208 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java @@ -22,10 +22,12 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; +import java.util.Base64; import java.util.Date; import java.util.concurrent.TimeUnit; @@ -40,14 +42,14 @@ public class OAuth2AppTokenFactory { public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) { Jws jwsClaims; try { - jwsClaims = Jwts.parser().setSigningKey(appSecret).parseClaimsJws(appToken); + jwsClaims = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(appSecret))).build().parseSignedClaims(appToken); } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { throw new IllegalArgumentException("Invalid Application token: ", ex); } catch (ExpiredJwtException expiredEx) { throw new IllegalArgumentException("Application token expired", expiredEx); } - Claims claims = jwsClaims.getBody(); + Claims claims = jwsClaims.getPayload(); Date expiration = claims.getExpiration(); if (expiration == null) { throw new IllegalArgumentException("Application token must have expiration date"); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 191d810c64..4c4fad1238 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -164,6 +164,8 @@ mail: oauth2: # Interval for checking refresh token expiration in seconds(by default, 1 day). refreshTokenCheckingInterval: "${REFRESH_TOKEN_EXPIRATION_CHECKING_INTERVAL:86400}" + # Rate limits for sending mails per tenant. As example for 1000 per minute and 10000 per hour is "1000:60,10000:3600" + per_tenant_rate_limits: "${MAIL_PER_TENANT_RATE_LIMITS:}" # Usage statistics parameters usage: diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 96e52d6c12..b83a1b567c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -21,9 +21,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Header; -import io.jsonwebtoken.Jwt; -import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.Jws; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.hamcrest.Matcher; @@ -121,6 +119,7 @@ import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import java.io.IOException; import java.lang.invoke.MethodHandles; @@ -241,6 +240,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @Autowired protected ClaimDevicesService claimDevicesService; + @Autowired + private JwtTokenFactory jwtTokenFactory; + @SpyBean protected MailService mailService; @@ -561,13 +563,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { } protected void validateJwtToken(String token, String username) { - Assert.assertNotNull(token); - Assert.assertFalse(token.isEmpty()); - int i = token.lastIndexOf('.'); - Assert.assertTrue(i > 0); - String withoutSignature = token.substring(0, i + 1); - Jwt jwsClaims = Jwts.parser().parseClaimsJwt(withoutSignature); - Claims claims = jwsClaims.getBody(); + Jws jwsClaims = jwtTokenFactory.parseTokenClaims(token); + Claims claims = jwsClaims.getPayload(); String subject = claims.getSubject(); Assert.assertEquals(username, subject); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java index 430ff2eb09..8876bf8c24 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java @@ -42,7 +42,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @Slf4j @DaoSqlTest public class AdminControllerTest extends AbstractControllerTest { - final JwtSettings defaultJwtSettings = new JwtSettings(9000, 604800, "thingsboard.io", "thingsboardDefaultSigningKey"); + final JwtSettings defaultJwtSettings = new JwtSettings(9000, 604800, "thingsboard.io", "QmlicmJkZk9tSzZPVFozcWY0Sm94UVhybmtBWXZ5YmZMOUZSZzZvcUFiOVhsb3VHUThhUWJGaXp3UHhtcGZ6Tw=="); @Test public void testFindAdminSettingsByKey() throws Exception { @@ -168,7 +168,7 @@ public class AdminControllerTest extends AbstractControllerTest { assertThat(jwtSettings).isEqualTo(defaultJwtSettings); jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString( - RandomStringUtils.randomAlphanumeric(256 / Byte.SIZE).getBytes(StandardCharsets.UTF_8))); + RandomStringUtils.randomAlphanumeric(512 / Byte.SIZE).getBytes(StandardCharsets.UTF_8))); doPost("/api/admin/jwtSettings", jwtSettings).andExpect(status().isOk()); diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index 6bd0cd9c0e..8e0b294241 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -640,7 +640,7 @@ public class TbRuleEngineQueueConsumerManagerTest { } private void verifyMsgProcessed(TbMsg tbMsg) { - await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(15, TimeUnit.SECONDS).untilAsserted(() -> { verify(actorContext, atLeastOnce()).tell(argThat(msg -> { return ((QueueToRuleEngineMsg) msg).getMsg().getId().equals(tbMsg.getId()); })); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java index 2d39dd9905..e6c6cfbac4 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java @@ -16,16 +16,14 @@ package org.thingsboard.server.service.security.auth; import io.jsonwebtoken.Claims; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.Before; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.common.data.security.model.JwtToken; @@ -38,6 +36,8 @@ import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.AccessJwtToken; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Calendar; import java.util.Date; import java.util.Optional; @@ -45,19 +45,13 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class JwtTokenFactoryTest { private JwtTokenFactory tokenFactory; private AdminSettingsService adminSettingsService; - private NotificationCenter notificationCenter; private JwtSettingsService jwtSettingsService; private JwtSettings jwtSettings; @@ -66,12 +60,11 @@ public class JwtTokenFactoryTest { public void beforeEach() { jwtSettings = new JwtSettings(); jwtSettings.setTokenIssuer("tb"); - jwtSettings.setTokenSigningKey("abewafaf"); + jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); jwtSettings.setTokenExpirationTime((int) TimeUnit.HOURS.toSeconds(2)); jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7)); adminSettingsService = mock(AdminSettingsService.class); - notificationCenter = mock(NotificationCenter.class); jwtSettingsService = mockJwtSettingsService(); mockJwtSettings(jwtSettings); @@ -169,21 +162,6 @@ public class JwtTokenFactoryTest { }); } - @Test - public void testJwtSigningKeyIssueNotification() { - JwtSettings badJwtSettings = jwtSettings; - badJwtSettings.setTokenSigningKey(JwtSettingsService.TOKEN_SIGNING_KEY_DEFAULT); - mockJwtSettings(badJwtSettings); - jwtSettingsService = mockJwtSettingsService(); - - for (int i = 0; i < 5; i++) { // to check if notification is not sent twice - jwtSettingsService.getJwtSettings(); - } - verify(notificationCenter, times(1)).sendGeneralWebNotification(eq(TenantId.SYS_TENANT_ID), - isA(SystemAdministratorsFilter.class), argThat(template -> template.getConfiguration().getDeliveryMethodsTemplates().get(NotificationDeliveryMethod.WEB) - .getBody().contains("The platform is configured to use default JWT Signing Key"))); - } - private void mockJwtSettings(JwtSettings settings) { AdminSettings adminJwtSettings = new AdminSettings(); adminJwtSettings.setJsonValue(JacksonUtil.valueToTree(settings)); @@ -192,12 +170,11 @@ public class JwtTokenFactoryTest { } private DefaultJwtSettingsService mockJwtSettingsService() { - return new DefaultJwtSettingsService(adminSettingsService, Optional.empty(), - Optional.of(notificationCenter), new DefaultJwtSettingsValidator()); + return new DefaultJwtSettingsService(adminSettingsService, Optional.empty(), new DefaultJwtSettingsValidator(), Optional.empty()); } private void checkExpirationTime(JwtToken jwtToken, int tokenLifetime) { - Claims claims = tokenFactory.parseTokenClaims(jwtToken.getToken()).getBody(); + Claims claims = tokenFactory.parseTokenClaims(jwtToken.getToken()).getPayload(); assertThat(claims.getExpiration()).matches(actualExpirationTime -> { Calendar expirationTime = Calendar.getInstance(); expirationTime.setTime(new Date()); diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/limits/DefaultRateLimitService.java b/common/cache/src/main/java/org/thingsboard/server/cache/limits/DefaultRateLimitService.java index f3530e99d2..9680fd2b96 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/limits/DefaultRateLimitService.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/limits/DefaultRateLimitService.java @@ -63,12 +63,21 @@ public class DefaultRateLimitService implements RateLimitService { @Override public boolean checkRateLimit(LimitedApi api, TenantId tenantId, Object level) { + return checkRateLimit(api, tenantId, level, false); + } + + @Override + public boolean checkRateLimit(LimitedApi api, TenantId tenantId, Object level, boolean ignoreTenantNotFound) { if (tenantId.isSysTenantId()) { return true; } TenantProfile tenantProfile = tenantProfileProvider.get(tenantId); if (tenantProfile == null) { - throw new TenantProfileNotFoundException(tenantId); + if (ignoreTenantNotFound) { + return true; + } else { + throw new TenantProfileNotFoundException(tenantId); + } } String rateLimitConfig = tenantProfile.getProfileConfiguration() diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/limits/RateLimitService.java b/common/cache/src/main/java/org/thingsboard/server/cache/limits/RateLimitService.java index 84c22c514b..3573ee2b8c 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/limits/RateLimitService.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/limits/RateLimitService.java @@ -24,6 +24,8 @@ public interface RateLimitService { boolean checkRateLimit(LimitedApi api, TenantId tenantId, Object level); + boolean checkRateLimit(LimitedApi api, TenantId tenantId, Object level, boolean ignoreTenantNotFound); + boolean checkRateLimit(LimitedApi api, Object level, String rateLimitConfig); void cleanUp(LimitedApi api, Object level); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/RateLimitExceededException.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/RateLimitExceededException.java new file mode 100644 index 0000000000..27be0f65d2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/RateLimitExceededException.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.exception; + +import org.thingsboard.server.common.data.limit.LimitedApi; + +public class RateLimitExceededException extends AbstractRateLimitException { + + public RateLimitExceededException(String message) { + super(message); + } + + public RateLimitExceededException(LimitedApi api) { + super("Rate limit for " + api.getLabel() + " is exceeded"); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java index 1a0f038c90..4afead3435 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java @@ -39,7 +39,8 @@ public enum LimitedApi { TWO_FA_VERIFICATION_CODE_SEND(false, true), TWO_FA_VERIFICATION_CODE_CHECK(false, true), TRANSPORT_MESSAGES_PER_TENANT("transport messages", true), - TRANSPORT_MESSAGES_PER_DEVICE("transport messages per device", false); + TRANSPORT_MESSAGES_PER_DEVICE("transport messages per device", false), + EMAILS("emails sending", true); private Function configExtractor; @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtSettings.java index d8368f247b..07c158830f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtSettings.java @@ -48,7 +48,7 @@ public class JwtSettings { * Key is used to sign {@link JwtToken}. * Base64 encoded */ - @Schema(description = "The JWT key is used to sing token. Base64 encoded.", example = "cTU4WnNqemI2aU5wbWVjdm1vYXRzanhjNHRUcXliMjE=") + @Schema(description = "The JWT key is used to sing token. Base64 encoded.", example = "dkVTUzU2M2VMWUNwVVltTUhQU2o5SUM0Tkc3M0k2Ykdwcm85QTl6R0RaQ252OFlmVDk2OEptZXBNcndGeExFZg==") private String tokenSigningKey; } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java index 714da709f7..93fe03e5cf 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java @@ -29,9 +29,8 @@ import org.thingsboard.server.common.data.notification.rule.trigger.Notification import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import org.thingsboard.server.queue.util.PropertyUtils; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT; @@ -41,7 +40,7 @@ import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType. @Slf4j public class DefaultNotificationDeduplicationService implements NotificationDeduplicationService { - private Map deduplicationDurations; + private ConcurrentMap deduplicationDurations; @Autowired(required = false) private CacheManager cacheManager; @@ -116,7 +115,7 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu @Autowired public void setDeduplicationDurations(@Value("${notification_system.rules.deduplication_durations:}") String deduplicationDurationsStr) { - this.deduplicationDurations = new HashMap<>(); + this.deduplicationDurations = new ConcurrentHashMap<>(); PropertyUtils.getProps(deduplicationDurationsStr).forEach((triggerType, duration) -> { this.deduplicationDurations.put(NotificationRuleTriggerType.valueOf(triggerType), Long.parseLong(duration)); }); diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index 4f617c2b9d..1a4c3ffab2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -372,15 +372,6 @@ public class DefaultNotifications { .build()) .build(); - public static final DefaultNotification jwtSigningKeyIssue = DefaultNotification.builder() - .name("JWT Signing Key issue notification") - .type(NotificationType.GENERAL) - .subject("WARNING: security issue") - .text("The platform is configured to use default JWT Signing Key. Please change it on the security settings page") - .icon("warning").color(YELLOW_COLOR) - .button("Go to settings").link("/security-settings/general") - .build(); - private final NotificationTemplateService templateService; private final NotificationRuleService ruleService; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java index f65e0db1c6..bf4798fa4d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java @@ -41,7 +41,9 @@ public interface AssetRepository extends JpaRepository, Expor AssetInfoEntity findAssetInfoById(@Param("assetId") UUID assetId); @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true)") Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); @@ -54,14 +56,16 @@ public interface AssetRepository extends JpaRepository, Expor "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + " OR ilike(p.name, CONCAT('%', :textSearch, '%')) = true " + - " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true) ") + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true)") Page findAssetInfosByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true)") Page findByTenantIdAndCustomerId(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("textSearch") String textSearch, @@ -69,7 +73,8 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.assetProfileId = :profileId " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true)") Page findByTenantIdAndProfileId(@Param("tenantId") UUID tenantId, @Param("profileId") UUID profileId, @Param("searchText") String searchText, @@ -81,7 +86,10 @@ public interface AssetRepository extends JpaRepository, Expor "LEFT JOIN AssetProfileEntity p on p.id = a.assetProfileId " + "WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(c.title, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(p.name, CONCAT('%', :searchText, '%')) = true) ") Page findAssetInfosByTenantIdAndCustomerId(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("searchText") String searchText, @@ -95,7 +103,8 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.type = :type " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true)") Page findByTenantIdAndType(@Param("tenantId") UUID tenantId, @Param("type") String type, @Param("textSearch") String textSearch, @@ -123,7 +132,8 @@ public interface AssetRepository extends JpaRepository, Expor "AND a.assetProfileId = :assetProfileId " + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + - " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true) ") + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true) ") Page findAssetInfosByTenantIdAndAssetProfileId(@Param("tenantId") UUID tenantId, @Param("assetProfileId") UUID assetProfileId, @Param("textSearch") String textSearch, @@ -132,7 +142,8 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId AND a.type = :type " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true) ") Page findByTenantIdAndCustomerIdAndType(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("type") String type, @@ -146,7 +157,9 @@ public interface AssetRepository extends JpaRepository, Expor "WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + "AND a.type = :type " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true) ") Page findAssetInfosByTenantIdAndCustomerIdAndType(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("type") String type, @@ -160,7 +173,10 @@ public interface AssetRepository extends JpaRepository, Expor "WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + "AND a.assetProfileId = :assetProfileId " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true) ") Page findAssetInfosByTenantIdAndCustomerIdAndAssetProfileId(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("assetProfileId") UUID assetProfileId, @@ -172,7 +188,9 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a, RelationEntity re WHERE a.tenantId = :tenantId " + "AND a.id = re.toId AND re.toType = 'ASSET' AND re.relationTypeGroup = 'EDGE' " + "AND re.relationType = 'Contains' AND re.fromId = :edgeId AND re.fromType = 'EDGE' " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :searchText, '%')) = true) ") Page findByTenantIdAndEdgeId(@Param("tenantId") UUID tenantId, @Param("edgeId") UUID edgeId, @Param("searchText") String searchText, @@ -182,7 +200,8 @@ public interface AssetRepository extends JpaRepository, Expor "AND a.id = re.toId AND re.toType = 'ASSET' AND re.relationTypeGroup = 'EDGE' " + "AND re.relationType = 'Contains' AND re.fromId = :edgeId AND re.fromType = 'EDGE' " + "AND a.type = :type " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true) ") Page findByTenantIdAndEdgeIdAndType(@Param("tenantId") UUID tenantId, @Param("edgeId") UUID edgeId, @Param("type") String type, diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 01ea937ba4..41680f76dd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -136,18 +136,20 @@ public class TenantServiceImpl extends AbstractCachedEntityService assets1 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(20, assets1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets2 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(10, assets2.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets3 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(0, assets3.getData().size()); + } + @Test public void testFindAssetsByTenantIdAndIdsAsync() throws ExecutionException, InterruptedException, TimeoutException { List searchIds = getAssetsUuids(tenantId1); @@ -180,21 +195,31 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { @Test public void testFindAssetsByTenantIdAndType() { String type = "TYPE_2"; - assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type)); + String testLabel = "test_label"; + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type, testLabel)); List foundedAssetsByType = assetDao .findAssetsByTenantIdAndType(tenantId2, type, new PageLink(3)).getData(); compareFoundedAssetByType(foundedAssetsByType, type); + + List foundedAssetsByTypeAndLabel = assetDao + .findAssetsByTenantIdAndType(tenantId2, type, new PageLink(3, 0, testLabel)).getData(); + assertEquals(1, foundedAssetsByTypeAndLabel.size()); } @Test public void testFindAssetsByTenantIdAndCustomerIdAndType() { String type = "TYPE_2"; - assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type)); + String testLabel = "test_label"; + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type, testLabel)); List foundedAssetsByType = assetDao .findAssetsByTenantIdAndCustomerIdAndType(tenantId2, customerId2, type, new PageLink(3)).getData(); compareFoundedAssetByType(foundedAssetsByType, type); + + List foundedAssetsByTypeAndLabel = assetDao + .findAssetsByTenantIdAndCustomerIdAndType(tenantId2, customerId2, type, new PageLink(3, 0, testLabel)).getData(); + assertEquals(1, foundedAssetsByTypeAndLabel.size()); } private void compareFoundedAssetByType(List foundedAssetsByType, String type) { @@ -228,10 +253,14 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { } private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name) { - return saveAsset(id, tenantId, customerId, name, null); + return saveAsset(id, tenantId, customerId, name, null, null); + } + + private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String label) { + return saveAsset(id, tenantId, customerId, name, null, label); } - private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String type) { + private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String type, String label) { if (type == null) { type = "default"; } @@ -241,6 +270,7 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { asset.setCustomerId(new CustomerId(customerId)); asset.setName(name); asset.setType(type); + asset.setLabel(label); asset.setAssetProfileId(assetProfileId(type)); return assetDao.save(TenantId.fromUUID(tenantId), asset); } diff --git a/dao/src/test/resources/sql/system-data.sql b/dao/src/test/resources/sql/system-data.sql index 7a79f1114d..0b17d4f108 100644 --- a/dao/src/test/resources/sql/system-data.sql +++ b/dao/src/test/resources/sql/system-data.sql @@ -53,6 +53,14 @@ VALUES ( '23199d80-6e7e-11ee-8829-ef9fd52a6141', 1697719852888, '13814000-1dd2-1 "coaps":{"enabled":false,"host":"","port":"5684"} }' ); +INSERT INTO admin_settings ( id, created_time, tenant_id, key, json_value ) +VALUES ( '1e33c6f0-061e-11ef-b5b7-dba0ee077a1b', 1714391189727, '13814000-1dd2-11b2-8080-808080808080', 'jwt', '{ + "tokenExpirationTime": "9000", + "refreshTokenExpTime": "604800", + "tokenIssuer": "thingsboard.io", + "tokenSigningKey": "QmlicmJkZk9tSzZPVFozcWY0Sm94UVhybmtBWXZ5YmZMOUZSZzZvcUFiOVhsb3VHUThhUWJGaXp3UHhtcGZ6Tw==" +}' ); + INSERT INTO queue ( id, created_time, tenant_id, name, topic, poll_interval, partitions, consumer_per_partition, pack_processing_timeout, submit_strategy, processing_strategy ) VALUES ( '6eaaefa6-4612-11e7-a919-92ebcb67fe33', 1592576748000 ,'13814000-1dd2-11b2-8080-808080808080', 'Main' ,'tb_rule_engine.main', 25, 10, true, 2000, '{"type": "BURST", "batchSize": 1000}', diff --git a/pom.xml b/pom.xml index 74c095c857..c3451544a4 100755 --- a/pom.xml +++ b/pom.xml @@ -49,7 +49,7 @@ 6.2.4 6.2.4 5.1.2 - 0.9.1 + 0.12.5 2.0.13 2.23.1 1.5.5 @@ -1476,6 +1476,12 @@ org.apache.httpcomponents httpclient ${apache-httpclient.version} + + + commons-logging + commons-logging + + org.apache.httpcomponents @@ -1943,6 +1949,12 @@ org.elasticsearch.client elasticsearch-rest-client ${elasticsearch.version} + + + commons-logging + commons-logging + + org.javadelight @@ -1968,11 +1980,23 @@ com.amazonaws aws-java-sdk-sqs ${aws.sdk.version} + + + commons-logging + commons-logging + + com.amazonaws aws-java-sdk-sns ${aws.sdk.version} + + + commons-logging + commons-logging + + com.google.cloud @@ -2102,6 +2126,10 @@ org.slf4j * + + commons-logging + commons-logging + com.github.spotbugs spotbugs-annotations @@ -2136,6 +2164,12 @@ mockserver-netty ${mock-server.version} test + + + commons-logging + commons-logging + + org.mock-server @@ -2168,6 +2202,12 @@ com.google.firebase firebase-admin ${firebase-admin.version} + + + commons-logging + commons-logging + + org.eclipse.jgit @@ -2203,6 +2243,12 @@ org.apache.xmlgraphics batik-transcoder ${apache-xmlgraphics.version} + + + commons-logging + commons-logging + + org.apache.xmlgraphics diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts index b524bd7cf8..63355805ad 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts @@ -163,6 +163,13 @@ export class EntitiesHierarchyWidgetComponent extends PageComponent implements O this.updateNodeData(this.subscription.data); } + public onEditModeChanged() { + if (this.textSearchMode) { + this.ctx.hideTitlePanel = !this.ctx.isEdit; + this.ctx.detectChanges(true); + } + } + private initializeConfig() { this.ctx.widgetActions = [this.searchAction]; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index 93b3a06ac2..9f9d9268c1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -266,6 +266,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.ctx.detectChanges(); } + public onEditModeChanged() { + if (this.textSearchMode) { + this.ctx.hideTitlePanel = !this.ctx.isEdit; + this.ctx.detectChanges(true); + } + } + public pageLinkSortDirection(): SortDirection { return entityDataPageLinkSortDirection(this.pageLink); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 77b86a29f4..b21614cb50 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -307,6 +307,13 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.ctx.detectChanges(); } + public onEditModeChanged() { + if (this.textSearchMode) { + this.ctx.hideTitlePanel = !this.ctx.isEdit; + this.ctx.detectChanges(true); + } + } + private initialize() { this.ctx.widgetActions = [this.searchAction, this.columnDisplayAction]; diff --git a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json index ae02012787..ec00225e6a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json +++ b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json @@ -482,9 +482,9 @@ "issuer-name": "اسم الجهة المصدرة", "issuer-name-required": "اسم الجهة المصدرة مطلوب.", "signings-key": "مفتاح التوقيع", - "signings-key-hint": "سلسلة مشفرة بتنسيق Base64 تمثل ما لا يقل عن 256 بت من البيانات.", + "signings-key-hint": "سلسلة مشفرة بتنسيق Base64 تمثل ما لا يقل عن 512 بت من البيانات.", "signings-key-required": "مفتاح التوقيع مطلوب.", - "signings-key-min-length": "يجب أن يكون مفتاح التوقيع ما لا يقل عن 256 بت من البيانات.", + "signings-key-min-length": "يجب أن يكون مفتاح التوقيع ما لا يقل عن 512 بت من البيانات.", "signings-key-base64": "يجب أن يكون مفتاح التوقيع بتنسيق base64.", "expiration-time": "وقت انتهاء صلاحية الرمز (ثانية)", "expiration-time-required": "وقت انتهاء صلاحية الرمز مطلوب.", diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 9cc4da72a2..83ffba634f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -457,9 +457,9 @@ "issuer-name": "Issuer name", "issuer-name-required": "Issuer name is required.", "signings-key": "Signing key", - "signings-key-hint": "Base64 encoded string representing at least 256 bits of data.", + "signings-key-hint": "Base64 encoded string representing at least 512 bits of data.", "signings-key-required": "Signing key is required.", - "signings-key-min-length": "Signing key must be at least 256 bits of data.", + "signings-key-min-length": "Signing key must be at least 512 bits of data.", "signings-key-base64": "Signing key must be base64 format.", "expiration-time": "Token expiration time (sec)", "expiration-time-required": "Token expiration time is required.", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index 4dd93cad1d..314a70e7fa 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -431,9 +431,9 @@ "issuer-name": "Nombre del emisor", "issuer-name-required": "Se requiere nombre del emisor.", "signings-key": "Clave de firma", - "signings-key-hint": "Una string codificada en Base64 representando por lo menos 256 bits de datos.", + "signings-key-hint": "Una string codificada en Base64 representando por lo menos 512 bits de datos.", "signings-key-required": "Se requiere clave de firma.", - "signings-key-min-length": "La clave de firma debe tener al menos 256 bits de datos.", + "signings-key-min-length": "La clave de firma debe tener al menos 512 bits de datos.", "signings-key-base64": "La clave de firma debe estar en formato base64.", "expiration-time": "Caducidad del token (en segundos)", "expiration-time-required": "Se requiere caducidad del token.", diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json index ed74dfe7fb..e8e2cb1f7d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json @@ -425,9 +425,9 @@ "issuer-name": "Naam van de uitgever", "issuer-name-required": "De naam van de uitgever is vereist.", "signings-key": "Sleutel ondertekenen", - "signings-key-hint": "Base64-gecodeerde tekenreeks die ten minste 256 bits aan gegevens vertegenwoordigt.", + "signings-key-hint": "Base64-gecodeerde tekenreeks die ten minste 512 bits aan gegevens vertegenwoordigt.", "signings-key-required": "Ondertekeningssleutel is vereist.", - "signings-key-min-length": "De ondertekeningssleutel moet ten minste 256 bits aan gegevens bevatten.", + "signings-key-min-length": "De ondertekeningssleutel moet ten minste 512 bits aan gegevens bevatten.", "signings-key-base64": "De ondertekeningssleutel moet de base64-indeling hebben.", "expiration-time": "Vervaltijd token (sec)", "expiration-time-required": "De vervaltijd van het token is vereist.", diff --git a/ui-ngx/src/assets/locale/locale.constant-pl_PL.json b/ui-ngx/src/assets/locale/locale.constant-pl_PL.json index a46c732c6f..b30d4afc38 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pl_PL.json +++ b/ui-ngx/src/assets/locale/locale.constant-pl_PL.json @@ -457,9 +457,9 @@ "issuer-name": "Nazwa emitenta", "issuer-name-required": "Nazwa emitenta jest wymagana.", "signings-key": "Klucz do podpisu", - "signings-key-hint": "Ciąg zakodowany w formacie Base64 reprezentujący co najmniej 256 bitów danych.", + "signings-key-hint": "Ciąg zakodowany w formacie Base64 reprezentujący co najmniej 512 bitów danych.", "signings-key-required": "Klucz do podpisu jest wymagany.", - "signings-key-min-length": "Klucz podpisujący musi mieć co najmniej 256 bitów danych.", + "signings-key-min-length": "Klucz podpisujący musi mieć co najmniej 512 bitów danych.", "signings-key-base64": "Klucz podpisujący musi być w formacie base64.", "expiration-time": "Czas ważności tokena (s)", "expiration-time-required": "Czas ważności tokena jest wymagany.", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 541d5a0ed6..99b13168c2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -451,9 +451,9 @@ "issuer-name": "发行者名称", "issuer-name-required": "发行者名称必填。", "signings-key": "签名密钥", - "signings-key-hint": "Base64编码的字符串,至少256位数据。", + "signings-key-hint": "Base64编码的字符串,至少512位数据。", "signings-key-required": "签名密钥必填。", - "signings-key-min-length": "签名密钥必须至少为256位的数据。", + "signings-key-min-length": "签名密钥必须至少为512位的数据。", "signings-key-base64": "签名密钥必须是Base64格式。", "expiration-time": "令牌过期时间(秒)", "expiration-time-required": "令牌过期时间是必填。",