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": "令牌过期时间是必填。",