diff --git a/application/src/main/data/upgrade/3.4.4/schema_update.sql b/application/src/main/data/upgrade/3.4.4/schema_update.sql index 985dea197c..3776eebc12 100644 --- a/application/src/main/data/upgrade/3.4.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.4/schema_update.sql @@ -609,4 +609,4 @@ BEGIN END $$; --- TTL DROP PARTITIONS FUNCTIONS UPDATE END \ No newline at end of file +-- TTL DROP PARTITIONS FUNCTIONS UPDATE END diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index 80b51f7ad5..e75b3c4b4e 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -20,15 +20,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; @@ -36,15 +37,16 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.device.DeviceCredentialsService; -import org.thingsboard.server.dao.device.DeviceDao; -import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; @@ -59,12 +61,13 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.state.DeviceStateService; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @@ -77,39 +80,28 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { private static final String DEVICE_PROVISION_STATE = "provisionState"; private static final String PROVISIONED_STATE = "provisioned"; - @Autowired - TbClusterService clusterService; + private final TbClusterService clusterService; + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final DeviceCredentialsService deviceCredentialsService; + private final AttributesService attributesService; + private final AuditLogService auditLogService; + private final PartitionService partitionService; - @Autowired - DeviceDao deviceDao; - - @Autowired - DeviceProfileDao deviceProfileDao; - - @Autowired - DeviceService deviceService; - - @Autowired - DeviceCredentialsService deviceCredentialsService; - - @Autowired - AttributesService attributesService; - - @Autowired - DeviceStateService deviceStateService; - - @Autowired - AuditLogService auditLogService; - - @Autowired - PartitionService partitionService; - - public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider) { + public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider, TbClusterService clusterService, DeviceProfileService deviceProfileService, DeviceService deviceService, DeviceCredentialsService deviceCredentialsService, AttributesService attributesService, AuditLogService auditLogService, PartitionService partitionService) { ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); + this.clusterService = clusterService; + this.deviceProfileService = deviceProfileService; + this.deviceService = deviceService; + this.deviceCredentialsService = deviceCredentialsService; + this.attributesService = attributesService; + this.auditLogService = auditLogService; + this.partitionService = partitionService; } @Override public ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) { + fetchAndApplyDeviceNameForX509ProvisionRequestWithRegEx(provisionRequest); String provisionRequestKey = provisionRequest.getCredentials().getProvisionDeviceKey(); String provisionRequestSecret = provisionRequest.getCredentials().getProvisionDeviceSecret(); if (!StringUtils.isEmpty(provisionRequest.getDeviceName())) { @@ -124,14 +116,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - DeviceProfile targetProfile = deviceProfileDao.findByProvisionDeviceKey(provisionRequestKey); + DeviceProfile targetProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequestKey); if (targetProfile == null || targetProfile.getProfileData().getProvisionConfiguration() == null || targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() == null) { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - Device targetDevice = deviceDao.findDeviceByTenantIdAndName(targetProfile.getTenantId().getId(), provisionRequest.getDeviceName()).orElse(null); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); switch (targetProfile.getProvisionType()) { case ALLOW_CREATE_NEW_DEVICES: @@ -155,6 +147,25 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } break; + case X509_CERTIFICATE_CHAIN: + if (targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret().equals(provisionRequestSecret)) { + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(targetDevice.getTenantId(), targetDevice.getId()); + if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { + String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials, + updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE); + } + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { + return createDevice(provisionRequest, targetProfile); + } else { + log.warn("Device with name {} doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration", provisionRequest.getDeviceName()); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } + break; } throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } @@ -209,6 +220,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } + private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, + DeviceCredentialsType credentialsType) { + log.trace("Updating device credentials [{}] with certificate value [{}]", deviceCredentials, certificateValue); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsType(credentialsType); + return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + } + private ListenableFuture> saveProvisionStateAttribute(Device device) { return attributesService.save(device.getTenantId(), device.getId(), DataConstants.SERVER_SCOPE, Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), @@ -257,4 +276,32 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { ActionType actionType = success ? ActionType.PROVISION_SUCCESS : ActionType.PROVISION_FAILURE; auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest); } + + private void fetchAndApplyDeviceNameForX509ProvisionRequestWithRegEx(ProvisionRequest provisionRequest) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequest.getCredentials().getProvisionDeviceKey()); + if (deviceProfile != null && DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProfileData().getProvisionConfiguration().getType())) { + X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + String certificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + String certificateRegEx = configuration.getCertificateRegExPattern(); + String deviceName = extractDeviceNameFromCertificateCNByRegEx(certificateValue, certificateRegEx); + if (deviceName == null) { + log.warn("Device name cannot be extracted using regex [{}] for certificate [{}]",certificateRegEx, certificateValue); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + provisionRequest.setDeviceName(deviceName); + } + } + + private String extractDeviceNameFromCertificateCNByRegEx(String x509Value, String regex) { + try { + String commonName = SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); + log.trace("Extract CN [{}] by regex pattern [{}]", commonName, regex); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(commonName); + if (matcher.find()) { + return matcher.group(0); + } + } catch (Exception ignored) {} + return null; + } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index bd840b2f52..1e54d8d48f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -82,6 +82,9 @@ public class DefaultCacheCleanupService implements CacheCleanupService { log.info("Clearing cache to upgrade from version 3.4.2 to 3.4.3 ..."); clearCacheByName("repositorySettings"); break; + case "3.4.4": + log.info("Clearing cache to upgrade from version 3.4.4 to 3.5.0"); + clearCacheByName("deviceProfiles"); default: //Do nothing, since cache cleanup is optional. } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 0c0ff3e0c9..27f20bb5fb 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -66,11 +66,13 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -108,8 +110,10 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.VALID; @@ -124,10 +128,13 @@ public class DefaultTransportApiService implements TransportApiService { private static final ObjectMapper mapper = new ObjectMapper(); + private static final Pattern X509_CERTIFICATE_TRIM_CHAIN_PATTERN = Pattern.compile("-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"); + private final TbDeviceProfileCache deviceProfileCache; private final TbTenantProfileCache tenantProfileCache; private final TbApiUsageStateService apiUsageStateService; private final DeviceService deviceService; + private final DeviceProfileService deviceProfileService; private final RelationService relationService; private final DeviceCredentialsService deviceCredentialsService; private final DbCallbackExecutorService dbCallbackExecutorService; @@ -159,6 +166,9 @@ public class DefaultTransportApiService implements TransportApiService { } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { + TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); + result = validateOrCreateDeviceX509Certificate(msg.getCertificateChain()); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -226,6 +236,32 @@ public class DefaultTransportApiService implements TransportApiService { } } + protected ListenableFuture validateOrCreateDeviceX509Certificate(String certificateChain) { + List chain = X509_CERTIFICATE_TRIM_CHAIN_PATTERN.matcher(certificateChain).results().map(match -> + EncryptionUtil.certTrimNewLines(match.group())).collect(Collectors.toList()); + for (String certificateValue : chain) { + String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); + if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { + return getDeviceInfo(credentials); + } + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash); + if (deviceProfile != null && deviceProfile.getProvisionType() == X509_CERTIFICATE_CHAIN) { + String updatedDeviceProvisionSecret = chain.get(0); + ProvisionRequest provisionRequest = createProvisionRequest(deviceProfile, updatedDeviceProvisionSecret); + ProvisionResponse provisionResponse = provisionDeviceRequestAndGetResponse(provisionRequest); + if (provisionResponse != null && ProvisionResponseStatus.SUCCESS.equals(provisionResponse.getResponseStatus())) { + return getDeviceInfo(provisionResponse.getDeviceCredentials()); + } else { + return getEmptyTransportApiResponseFuture(); + } + } else if (deviceProfile != null) { + log.warn("[{}] Device Profile provision configuration mismatched: expected {}, actual {}", deviceProfile.getId(), X509_CERTIFICATE_CHAIN, deviceProfile.getProvisionType()); + } + } + return getEmptyTransportApiResponseFuture(); + } + private ListenableFuture validateUserNameCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg mqtt) { DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(mqtt.getUserName()); if (credentials != null) { @@ -665,4 +701,24 @@ public class DefaultTransportApiService implements TransportApiService { private Long checkLong(Long l) { return l != null ? l : 0; } + + private ProvisionRequest createProvisionRequest(DeviceProfile deviceProfile, String certificateValue) { + ProvisionDeviceProfileCredentials provisionDeviceProfileCredentials = new ProvisionDeviceProfileCredentials( + deviceProfile.getProvisionDeviceKey(), + deviceProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() + ); + ProvisionDeviceCredentialsData provisionDeviceCredentialsData = new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue); + + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, provisionDeviceCredentialsData, provisionDeviceProfileCredentials); + } + + private ProvisionResponse provisionDeviceRequestAndGetResponse(ProvisionRequest provisionRequest) { + try { + return deviceProvisionService.provisionDevice(provisionRequest); + } catch (ProvisionFailedException e) { + log.error(e.getMessage()); + } + return null; + } + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index d51a2f228f..9f90b3457a 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -130,6 +130,9 @@ security: loginProcessingUrl: "${SECURITY_OAUTH2_LOGIN_PROCESSING_URL:/login/oauth2/code/}" githubMapper: emailUrl: "${SECURITY_OAUTH2_GITHUB_MAPPER_EMAIL_URL_KEY:https://api.github.com/user/emails}" + java_cacerts: + path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}${file.separator}lib${file.separator}security${file.separator}cacerts}" + password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" # Usage statistics parameters usage: diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java index 0df044cb26..fb4f9bd322 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java @@ -299,6 +299,28 @@ public abstract class BaseDeviceProfileControllerTest extends AbstractController tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); } + @Test + public void testSaveDeviceProfileWithSameCertificateHash() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setProvisionDeviceKey("Certificate hash"); + + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isOk()); + + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile 2"); + deviceProfile2.setProvisionDeviceKey("Certificate hash"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile with such provision device key already exists!"; + doPost("/api/deviceProfile", deviceProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + @Test public void testChangeDeviceProfileTypeNull() throws Exception { DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java new file mode 100644 index 0000000000..21a03c6167 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -0,0 +1,210 @@ +/** + * Copyright © 2016-2023 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.transport; + + +import com.google.common.util.concurrent.Futures; +import lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceProvisionService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.resource.TbResourceService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Slf4j +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = DefaultTransportApiService.class) +public class DefaultTransportApiServiceTest { + + @MockBean + protected TbDeviceProfileCache deviceProfileCache; + @MockBean + protected TbTenantProfileCache tenantProfileCache; + @MockBean + protected TbApiUsageStateService apiUsageStateService; + @MockBean + protected DeviceService deviceService; + @MockBean + protected DeviceProfileService deviceProfileService; + @MockBean + protected RelationService relationService; + @MockBean + protected DeviceCredentialsService deviceCredentialsService; + @MockBean + protected DbCallbackExecutorService dbCallbackExecutorService; + @MockBean + protected TbClusterService tbClusterService; + @MockBean + protected DataDecodingEncodingService dataDecodingEncodingService; + @MockBean + protected DeviceProvisionService deviceProvisionService; + @MockBean + protected TbResourceService resourceService; + @MockBean + protected OtaPackageService otaPackageService; + @MockBean + protected OtaPackageDataCache otaPackageDataCache; + @MockBean + protected QueueService queueService; + @SpyBean + DefaultTransportApiService service; + + private String certificateChain; + private String[] chain; + + @Before + public void setUp() { + String filePath = "src/test/resources/mqtt/x509ChainProvisionTest.pem"; + try { + certificateChain = Files.readString(Paths.get(filePath)); + certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain); + chain = fetchLeafCertificateFromChain(certificateChain); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void validateExistingDeviceX509Certificate() { + var device = createDevice(); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(deviceCredentials); + + service.validateOrCreateDeviceX509Certificate(certificateChain); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + } + + @Test + public void provisionDeviceX509Certificate() { + var deviceProfile = createDeviceProfile(chain[1]); + when(deviceProfileService.findDeviceProfileByProvisionDeviceKey(any())).thenReturn(deviceProfile); + + var device = createDevice(); + when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(null); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + var provisionResponse = createProvisionResponse(deviceCredentials); + when(deviceProvisionService.provisionDevice(any())).thenReturn(provisionResponse); + + service.validateOrCreateDeviceX509Certificate(certificateChain); + verify(deviceProfileService, times(1)).findDeviceProfileByProvisionDeviceKey(any()); + verify(deviceService, times(1)).findDeviceByIdAsync(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + verify(deviceProvisionService, times(1)).provisionDevice(any()); + } + + private DeviceProfile createDeviceProfile(String certificateValue) { + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setProvisionDeviceSecret(certificateValue); + provision.setCertificateRegExPattern("([^@]+)"); + provision.setAllowCreateNewDevicesByX509Certificate(true); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setProvisionConfiguration(provision); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); + return deviceProfile; + } + + private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsId(EncryptionUtil.getSha3Hash(certificateValue)); + deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + return deviceCredentials; + } + + private Device createDevice() { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + return device; + } + + private ProvisionResponse createProvisionResponse(DeviceCredentials deviceCredentials) { + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } + + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + + private String[] fetchLeafCertificateFromChain(String value) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(value); + while (matcher.find()) { + chain.add(matcher.group(0)); + } + return chain.toArray(new String[0]); + } +} diff --git a/application/src/test/resources/mqtt/x509ChainProvisionTest.pem b/application/src/test/resources/mqtt/x509ChainProvisionTest.pem new file mode 100644 index 0000000000..b2ec300f78 --- /dev/null +++ b/application/src/test/resources/mqtt/x509ChainProvisionTest.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUI9dBuwN6pTtK6uZ03rkiCwV4wEYwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTYxN1oXDTI0MDMyODE0NTYxN1ow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9Zo791qK +QiGNBm11r4ZGxh+w+ossZL3xc46ufq5QckQHP7zkD2XDAcmP5GvdkM1sBFN9AWaC +kQfNnWmfERsOOKNTMFEwHQYDVR0OBBYEFFFc5uyCyglQoZiKhzXzMcQ3BKORMB8G +A1UdIwQYMBaAFFFc5uyCyglQoZiKhzXzMcQ3BKORMA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIhANbA9CuhoOifZMMmqkpuld+65CR+ItKdXeRAhLMZ +uccuAiB0FSQB34zMutXrZj1g8Gl5OkE7YryFHbei1z0SveHR8g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUUEKxS9hTz4l+oLUMF0LV6TC/gCIwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTczNloXDTI0MDMyODE0NTczNlow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECMlWO72k +rDoUL9FQjUmSCetkhaEGJUfQkdSfkLSNa0GyAEIMbfmzI4zITeapunu4rGet3EMy +LydQzuQanBicp6NTMFEwHQYDVR0OBBYEFHpZ78tPnztNii4Da/yCw6mhEIL3MB8G +A1UdIwQYMBaAFHpZ78tPnztNii4Da/yCw6mhEIL3MA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIgJ7qyMFqNcwSYkH6o+UlQXzLWfwZbNjVk+aR7foAZ +NGsCIQDsd7v3WQIGHiArfZeDs1DLEDuV/2h6L+ZNoGNhEKL+1A== +-----END CERTIFICATE----- diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index 65a57a5cc5..5198c6fc8d 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -179,6 +179,10 @@ message ValidateDeviceX509CertRequestMsg { string hash = 1; } +message ValidateOrCreateDeviceX509CertRequestMsg { + string certificateChain = 1; +} + message ValidateBasicMqttCredRequestMsg { string clientId = 1; string userName = 2; @@ -942,6 +946,7 @@ message TransportApiRequestMsg { GetDeviceRequestMsg deviceRequestMsg = 12; GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 13; GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 14; + ValidateOrCreateDeviceX509CertRequestMsg validateOrCreateX509CertRequestMsg = 15; } /* Response from ThingsBoard Core Service to Transport Service */ diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java index 8d17e84b01..c57b316a44 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.dao.device; +import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; -import com.fasterxml.jackson.databind.JsonNode; public interface DeviceCredentialsService { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java index 5fc45afb57..e765cc5030 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java @@ -39,6 +39,8 @@ public interface DeviceProfileService extends EntityDaoService { PageData findDeviceProfileInfos(TenantId tenantId, PageLink pageLink, String transportType); + DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey); + DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String profileName); DeviceProfile createDefaultDeviceProfile(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java index 7af4361698..f5ef1319a8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java @@ -18,5 +18,6 @@ package org.thingsboard.server.common.data; public enum DeviceProfileProvisionType { DISABLED, ALLOW_CREATE_NEW_DEVICES, - CHECK_PRE_PROVISIONED_DEVICES + CHECK_PRE_PROVISIONED_DEVICES, + X509_CERTIFICATE_CHAIN } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index 4a825ab96f..c5882dc187 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -31,7 +31,8 @@ import java.io.Serializable; @JsonSubTypes({ @JsonSubTypes.Type(value = DisabledDeviceProfileProvisionConfiguration.class, name = "DISABLED"), @JsonSubTypes.Type(value = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class, name = "ALLOW_CREATE_NEW_DEVICES"), - @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES")}) + @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES"), + @JsonSubTypes.Type(value = X509CertificateChainProvisionConfiguration.class, name = "X509_CERTIFICATE_CHAIN")}) public interface DeviceProfileProvisionConfiguration extends Serializable { String getProvisionDeviceSecret(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java new file mode 100644 index 0000000000..d5edafca69 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2023 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.device.profile; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; + +@Data +@NoArgsConstructor +public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { + + private String provisionDeviceSecret; + private String certificateRegExPattern; + private boolean allowCreateNewDevicesByX509Certificate; + + @Override + public DeviceProfileProvisionType getType() { + return DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 2c01e11712..e944a8dd92 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -35,6 +35,14 @@ public class EncryptionUtil { .replaceAll("-----END CERTIFICATE-----", ""); } + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + public static String pubkTrimNewLines(String input) { return input.replaceAll("-----BEGIN PUBLIC KEY-----", "") .replaceAll("\n", "") diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 4b425e8446..1cc4f90915 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -24,9 +24,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; @@ -42,7 +41,6 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.concurrent.CountDownLatch; @@ -123,7 +121,7 @@ public class MqttSslHandlerProvider { static class ThingsboardMqttX509TrustManager implements X509TrustManager { private final X509TrustManager trustManager; - private TransportService transportService; + private final TransportService transportService; ThingsboardMqttX509TrustManager(X509TrustManager trustManager, TransportService transportService) { this.trustManager = trustManager; @@ -142,43 +140,40 @@ public class MqttSslHandlerProvider { } @Override - public void checkClientTrusted(X509Certificate[] chain, - String authType) throws CertificateException { - String credentialsBody = null; - for (X509Certificate cert : chain) { - try { - String strCert = SslUtil.getCertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - final String[] credentialsBodyHolder = new String[1]; - CountDownLatch latch = new CountDownLatch(1); - transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - if (!StringUtils.isEmpty(msg.getCredentials())) { - credentialsBodyHolder[0] = msg.getCredentials(); - } - latch.countDown(); + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); + final String[] credentialsBodyHolder = new String[1]; + CountDownLatch latch = new CountDownLatch(1); + try { + String certificateChain = SslUtil.getCertificateChainString(chain); + transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg + .newBuilder().setCertificateChain(certificateChain).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + if (!StringUtils.isEmpty(msg.getCredentials())) { + credentialsBodyHolder[0] = msg.getCredentials(); } + latch.countDown(); + } - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - }); - latch.await(10, TimeUnit.SECONDS); - if (strCert.equals(credentialsBodyHolder[0])) { - credentialsBody = credentialsBodyHolder[0]; - break; + @Override + public void onError(Throwable e) { + log.trace("Failed to process certificate chain: {}", certificateChain, e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + log.debug("Failed to find credentials for device certificate chain: {}", chain); + if (chain.length == 1) { + throw new CertificateException("Invalid Device Certificate"); + } else { + throw new CertificateException("Invalid Chain of X509 Certificates"); } - } catch (InterruptedException | CertificateEncodingException e) { - log.error(e.getMessage(), e); } - } - if (credentialsBody == null) { - log.debug("Failed to find credentials for device certificate chain: {}", chain); - throw new CertificateException("Invalid Device Certificate"); + } catch (Exception e) { + log.error(e.getMessage(), e); } } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 9440ff8e79..abcd35ee23 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -54,8 +54,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMs import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateBasicMqttCredRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg; import java.util.List; import java.util.concurrent.ExecutorService; @@ -87,6 +88,9 @@ public interface TransportService { void process(DeviceTransportType transportType, ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); + void process(DeviceTransportType transportType, ValidateOrCreateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); + void process(ValidateDeviceLwM2MCredentialsRequestMsg msg, TransportServiceCallback callback); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 1bc6687465..883522881f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -72,7 +72,6 @@ import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGateway import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.common.transport.limits.TransportRateLimitService; -import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; @@ -97,6 +96,7 @@ import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbTransportQueueFactory; import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.queue.util.TbTransportComponent; import javax.annotation.PostConstruct; @@ -434,6 +434,13 @@ public class DefaultTransportService implements TransportService { doProcess(transportType, protoMsg, callback); } + @Override + public void process(DeviceTransportType transportType, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + log.trace("Processing msg: {}", msg); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setValidateOrCreateX509CertRequestMsg(msg).build()); + doProcess(transportType, protoMsg, callback); + } + private void doProcess(DeviceTransportType transportType, TbProtoQueueMsg protoMsg, TransportServiceCallback callback) { ListenableFuture response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 0431868965..089718ec05 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -16,11 +16,21 @@ package org.thingsboard.server.common.transport.util; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.springframework.util.Base64Utils; import org.thingsboard.server.common.msg.EncryptionUtil; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; /** * @author Valerii Sosliuk @@ -35,4 +45,44 @@ public class SslUtil { throws CertificateEncodingException { return EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded())); } + + public static String getCertificateChainString(Certificate[] chain) + throws CertificateEncodingException { + String begin = "-----BEGIN CERTIFICATE-----"; + String end = "-----END CERTIFICATE-----"; + StringBuilder stringBuilder = new StringBuilder(); + for (Certificate cert: chain) { + stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded()))).append(end).append("\n"); + } + return stringBuilder.toString(); + } + + public static X509Certificate readCertFile(String fileContent) { + X509Certificate certificate = null; + try { + if (fileContent != null && !fileContent.trim().isEmpty()) { + fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.decodeBase64(fileContent); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (InputStream inStream = new ByteArrayInputStream(decoded)) { + certificate = (X509Certificate) certFactory.generateCertificate(inStream); + } + } + } catch (Exception ignored) {} + return certificate; + } + + public static String parseCommonName(X509Certificate certificate) { + X500Name x500name; + try { + x500name = new JcaX509CertificateHolder(certificate).getSubject(); + } catch (CertificateEncodingException e) { + log.warn("Cannot parse CN from device certificate"); + throw new RuntimeException(e); + } + RDN cn = x500name.getRDNs(BCStyle.CN)[0]; + return IETFUtils.valueToString(cn.getFirst().getValue()); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 360085be20..6e79b953ff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -16,15 +16,12 @@ package org.thingsboard.server.dao.device; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.SecurityMode; import org.eclipse.leshan.core.util.SecurityUtil; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index 07428064a8..d032282da9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.device; import lombok.Data; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; @@ -30,34 +31,44 @@ public class DeviceProfileCacheKey implements Serializable { private final String name; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String provisionDeviceKey; - private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile) { + private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile, String provisionDeviceKey) { this.tenantId = tenantId; this.name = name; this.deviceProfileId = deviceProfileId; this.defaultProfile = defaultProfile; + this.provisionDeviceKey = provisionDeviceKey; } public static DeviceProfileCacheKey fromName(TenantId tenantId, String name) { - return new DeviceProfileCacheKey(tenantId, name, null, false); + return new DeviceProfileCacheKey(tenantId, name, null, false, null); } public static DeviceProfileCacheKey fromId(DeviceProfileId id) { - return new DeviceProfileCacheKey(null, null, id, false); + return new DeviceProfileCacheKey(null, null, id, false, null); } public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { - return new DeviceProfileCacheKey(tenantId, null, null, true); + return new DeviceProfileCacheKey(tenantId, null, null, true, null); } + public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { + return new DeviceProfileCacheKey(null, null, null, false, provisionDeviceKey); + } + + /** + * IMPORTANT: Method toString() has to return unique value, if you add additional field to this class, please also refactor toString(). + */ @Override public String toString() { if (deviceProfileId != null) { return deviceProfileId.toString(); } else if (defaultProfile) { return tenantId.toString(); - } else { - return tenantId + "_" + name; + } else if (StringUtils.isNotEmpty(provisionDeviceKey)) { + return provisionDeviceKey; } + return tenantId + "_" + name; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java index c5a699b035..6470bfc584 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java @@ -27,5 +27,6 @@ public class DeviceProfileEvictEvent { private final String oldName; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String provisionDeviceKey; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 98ca4fde7b..a5b3e56341 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -34,12 +34,14 @@ import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileCon import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.queue.QueueService; @@ -47,12 +49,19 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; +import java.io.ByteArrayInputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validateString; @Service("DeviceProfileDaoService") @Slf4j @@ -61,6 +70,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true); } @@ -107,30 +120,46 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findByName(tenantId, profileName), true); } + @Override + public DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey) { + log.trace("Executing findDeviceProfileByProvisionDeviceKey provisionKey [{}]", provisionDeviceKey); + validateString(provisionDeviceKey, INCORRECT_PROVISION_DEVICE_KEY + provisionDeviceKey); + return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromProvisionDeviceKey(provisionDeviceKey), + () -> deviceProfileDao.findByProvisionDeviceKey(provisionDeviceKey), false); + } + @Override public DeviceProfileInfo findDeviceProfileInfoById(TenantId tenantId, DeviceProfileId deviceProfileId) { log.trace("Executing findDeviceProfileById [{}]", deviceProfileId); - Validator.validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); + validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); return toDeviceProfileInfo(findDeviceProfileById(tenantId, deviceProfileId)); } @Override public DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile) { log.trace("Executing saveDeviceProfile [{}]", deviceProfile); + if (deviceProfile.getProfileData() != null && deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + if (x509Configuration.getProvisionDeviceSecret() != null) { + formatDeviceProfileCertificate(deviceProfile, x509Configuration); + } + } DeviceProfile oldDeviceProfile = deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId); DeviceProfile savedDeviceProfile; try { savedDeviceProfile = deviceProfileDao.saveAndFlush(deviceProfile.getTenantId(), deviceProfile); publishEvictEvent(new DeviceProfileEvictEvent(savedDeviceProfile.getTenantId(), savedDeviceProfile.getName(), - oldDeviceProfile != null ? oldDeviceProfile.getName() : null, savedDeviceProfile.getId(), savedDeviceProfile.isDefault())); + oldDeviceProfile != null ? oldDeviceProfile.getName() : null, savedDeviceProfile.getId(), savedDeviceProfile.isDefault(), + oldDeviceProfile != null ? oldDeviceProfile.getProvisionDeviceKey() : null)); } catch (Exception t) { handleEvictEvent(new DeviceProfileEvictEvent(deviceProfile.getTenantId(), deviceProfile.getName(), - oldDeviceProfile != null ? oldDeviceProfile.getName() : null, null, deviceProfile.isDefault())); + oldDeviceProfile != null ? oldDeviceProfile.getName() : null, null, deviceProfile.isDefault(), + oldDeviceProfile != null ? oldDeviceProfile.getProvisionDeviceKey() : null)); checkConstraintViolation(t, Map.of("device_profile_name_unq_key", DEVICE_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS, "device_provision_key_unq_key", "Device profile with such provision device key already exists!", @@ -156,7 +185,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService 1) { + return EncryptionUtil.certTrimNewLinesForChainInDeviceProfile(certificateValue); + } + } catch (CertificateException ignored) {} + return EncryptionUtil.certTrimNewLines(certificateValue); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 400cc0e561..7b142d08e3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -19,8 +19,10 @@ import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; import org.eclipse.leshan.core.util.SecurityUtil; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; import org.springframework.util.CollectionUtils; import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DeviceProfile; @@ -55,6 +57,17 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -85,6 +98,12 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator -
- - device-profile.provision-device-key - - - - {{ 'device-profile.provision-device-key-required' | translate }} - - - - device-profile.provision-device-secret - - - - {{ 'device-profile.provision-device-secret-required' | translate }} - - -
+
+ + + + + + + +
+ + +
+ + {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} + +
+ + device-profile.provision-strategy-x509.certificate-value + + + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + + + + device-profile.provision-strategy-x509.cn-regex-variable + + + + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + + device-profile.provision-strategy-x509.cn-regex-variable-hint + +
+ +
+ + device-profile.provision-device-key + + + + {{ 'device-profile.provision-device-key-required' | translate }} + + + + device-profile.provision-device-secret + + + + {{ 'device-profile.provision-device-secret-required' | translate }} + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 872571dc20..54a9b98aa5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -32,7 +32,7 @@ import { DeviceProvisionType, deviceProvisionTypeTranslationMap } from '@shared/models/device.models'; -import { generateSecret, isDefinedAndNotNull } from '@core/utils'; +import { generateSecret, isBoolean, isDefinedAndNotNull } from '@core/utils'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -86,14 +86,36 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup = this.fb.group({ type: [DeviceProvisionType.DISABLED, Validators.required], provisionDeviceSecret: [{value: null, disabled: true}, Validators.required], - provisionDeviceKey: [{value: null, disabled: true}, Validators.required] + provisionDeviceKey: [{value: null, disabled: true}, Validators.required], + certificateValue: [{value: null, disabled: true}, Validators.required], + certificateRegExPattern: [{value: null, disabled: true}, Validators.required], + allowCreateNewDevicesByX509Certificate: [{value: null, disabled: true}, Validators.required] }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').patchValue(null, {emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').patchValue(null); + for (const field in this.provisionConfigurationFormGroup.controls) { + if (field !== 'type') { + const control = this.provisionConfigurationFormGroup.get(field); + control.disable({emitEvent: false}); + control.patchValue(null, {emitEvent: false}); + } + } + } else if (type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + const certificateValue: string = this.provisionConfigurationFormGroup.get('certificateValue').value; + if (!certificateValue || !certificateValue.length) { + this.provisionConfigurationFormGroup.get('certificateValue').patchValue(null, {emitEvent: false}); + } + const certificateRegExPattern: string = this.provisionConfigurationFormGroup.get('certificateRegExPattern').value; + if (!certificateRegExPattern || !certificateRegExPattern.length) { + this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue('[\\w]*', {emitEvent: false}); + } + const allowCreateNewDevicesByX509Certificate: boolean | null = this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').value; + if (!isBoolean(allowCreateNewDevicesByX509Certificate)) { + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(true, {emitEvent: false}); + } + this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); } else { const provisionDeviceSecret: string = this.provisionConfigurationFormGroup.get('provisionDeviceSecret').value; if (!provisionDeviceSecret || !provisionDeviceSecret.length) { @@ -120,16 +142,19 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu } writeValue(value: DeviceProvisionConfiguration | null): void { - if (isDefinedAndNotNull(value)){ + if (isDefinedAndNotNull(value)) { + if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + value.certificateValue = value.provisionDeviceSecret; + } this.provisionConfigurationFormGroup.patchValue(value, {emitEvent: false}); } else { this.provisionConfigurationFormGroup.patchValue({type: DeviceProvisionType.DISABLED}); } } - setDisabledState(isDisabled: boolean){ + setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; - if (this.disabled){ + if (this.disabled) { this.provisionConfigurationFormGroup.disable({emitEvent: false}); } else { if (this.provisionConfigurationFormGroup.get('type').value !== DeviceProvisionType.DISABLED) { @@ -150,8 +175,12 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu private updateModel(): void { let deviceProvisionConfiguration: DeviceProvisionConfiguration = null; + this.resetFormControls(this.provisionConfigurationFormGroup.value); if (this.provisionConfigurationFormGroup.valid) { deviceProvisionConfiguration = this.provisionConfigurationFormGroup.getRawValue(); + if (deviceProvisionConfiguration.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + deviceProvisionConfiguration.provisionDeviceSecret = deviceProvisionConfiguration.certificateValue; + } } this.propagateChange(deviceProvisionConfiguration); } @@ -166,4 +195,15 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu horizontalPosition: 'right' })); } + + private resetFormControls(value: DeviceProvisionConfiguration) { + if (value.type === DeviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES || value.type === DeviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { + this.provisionConfigurationFormGroup.get('certificateValue').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').reset({value: null, disabled: true}, {emitEvent: false}); + } else if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + this.provisionConfigurationFormGroup.get('provisionDeviceSecret').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false}); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index 9adcf9c168..db6539318d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -104,7 +104,9 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; const form = this.fb.group( { @@ -185,7 +187,9 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; this.entityForm.patchValue({name: entity.name}); this.entityForm.patchValue({type: entity.type}, {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 17feca514b..45cf2040b2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -169,6 +169,6 @@ {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} -
+
diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index ae601b0039..de240934c4 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -62,7 +62,8 @@ export enum CoapTransportDeviceType { export enum DeviceProvisionType { DISABLED = 'DISABLED', ALLOW_CREATE_NEW_DEVICES = 'ALLOW_CREATE_NEW_DEVICES', - CHECK_PRE_PROVISIONED_DEVICES = 'CHECK_PRE_PROVISIONED_DEVICES' + CHECK_PRE_PROVISIONED_DEVICES = 'CHECK_PRE_PROVISIONED_DEVICES', + X509_CERTIFICATE_CHAIN = 'X509_CERTIFICATE_CHAIN' } export interface DeviceConfigurationFormInfo { @@ -110,7 +111,8 @@ export const deviceProvisionTypeTranslationMap = new MapThis strategy can: +* check for pre-provisioned devices +* update X.509 device credentials +* create new devices + +The user uploads X.509 certificate to the device profile and sets a regular expression to fetch the device name from *Common Name (CN)*. + +Client certificates must be signed by X.509 certificate, pre-uploaded for this device profile to provision devices by the strategy. + +The client must establish a TLS connection using the entire chain of certificates (this chain must include device profile X.509 certificate on the last level). + +If a device already exists with outdated X.509 credentials, this strategy automatically updates it with the device certificate's credentials from the chain. + +Important: Uploaded certificates should be neither root nor intermediate certificates that are provided by a well-known *Certificate Authority (CA)*. diff --git a/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md b/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md new file mode 100644 index 0000000000..bb8a069473 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md @@ -0,0 +1,15 @@ +#### Examples of RegEx usage + +* **Pattern:** .* - matches any character (until line terminators) +
**CN sample:** DeviceName\nAdditionalInfo +
**Pattern matches:** DeviceName + +* **Pattern:** ^([^@]+) - matches any string that starts with one or more characters that are not the @ symbol (@ could be replaced by any other symbol) +
**CN sample:** DeviceName@AdditionalInfo +
**Pattern matches:** DeviceName + +* **Pattern:** [\w]*$ (equivalent to [a-zA-Z0-9_]\*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string +
**CN sample:** AdditionalInfo2110#DeviceName_01 +
**Pattern matches:** DeviceName_01 + +**Note:** Client will get error response in case regex is failed to match. 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 75c373250e..1697d55d0c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -63,6 +63,8 @@ "print": "Print", "restore": "Restore", "confirm": "Confirm", + "more": "More", + "less": "Less", "skip": "Skip", "send": "Send" }, @@ -1516,6 +1518,17 @@ "provision-device-secret-required": "Provision device secret is required.", "copy-provision-secret": "Copy provision secret", "provision-secret-copied-message": "Provision secret has been copied to clipboard", + "provision-strategy-x509": { + "certificate-chain": "X509 Certificates Chain", + "certificate-chain-hint": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication.", + "allow-create-new-devices": "Create new devices", + "allow-create-new-devices-hint": "If selected new devices will be created and client certificate will be used as device credentials.", + "certificate-value": "Certificate in PEM format", + "certificate-value-required": "Certificate in PEM format is required", + "cn-regex-variable": "CN Regular Expression variable", + "cn-regex-variable-required": "CN Regular Expression variable is required", + "cn-regex-variable-hint": "Required to fetch device name from device's X509 certificate's common name." + }, "condition": "Condition", "condition-type": "Condition type", "condition-type-simple": "Simple",