Refactor after review and add tests for extracting by regex

This commit is contained in:
Andrii Landiak 2023-04-12 17:50:26 +03:00
parent 5d84945ccd
commit fbeb56cf70
8 changed files with 315 additions and 36 deletions

View File

@ -100,7 +100,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
}
@Override
public ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile targetProfile, ProvisionRequest provisionRequest) {
public ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile targetProfile, ProvisionRequest provisionRequest) throws ProvisionFailedException {
if (targetProfile == null) {
throw new ProvisionFailedException("Device profile is not specified!");
}
@ -110,9 +110,10 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration();
String certificateValue = provisionRequest.getCredentialsData().getX509CertHash();
String certificateRegEx = configuration.getCertificateRegExPattern();
String deviceName = extractDeviceNameFromCertificateCNByRegEx(targetProfile, certificateValue, certificateRegEx);
String commonName = getCNFromX509Certificate(certificateValue);
String deviceName = extractDeviceNameFromCNByRegEx(targetProfile, commonName, certificateRegEx);
if (StringUtils.isBlank(deviceName)) {
log.warn("Device name cannot be extracted using regex [{}] for certificate [{}]", certificateRegEx, certificateValue);
log.warn("[{}][{}] Failed to extract device name using [{}] and certificate: [{}]", targetProfile.getTenantId(), targetProfile.getId(), certificateRegEx, certificateValue);
throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name());
}
provisionRequest.setDeviceName(deviceName);
@ -120,7 +121,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
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) {
if (DeviceCredentialsType.X509_CERTIFICATE.equals(deviceCredentials.getCredentialsType())) {
String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash();
deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials,
updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE);
@ -295,21 +296,25 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest);
}
private String extractDeviceNameFromCertificateCNByRegEx(DeviceProfile profile, String x509Value, String regex) {
private String getCNFromX509Certificate(String x509Value) {
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(1);
} else {
return null;
}
} catch (Exception ignored) {
log.trace("[{}][{}] Failed to extract device name using [{}] and certificate: [{}]", profile.getTenantId(), profile.getId(), regex, x509Value);
return SslUtil.parseCommonName(SslUtil.readCertFile(x509Value));
} catch (Exception e) {
return null;
}
}
public String extractDeviceNameFromCNByRegEx(DeviceProfile profile, String commonName, String regex) {
try {
log.trace("Extract device name from CN [{}] by regex pattern [{}]", commonName, regex);
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(commonName);
if (matcher.find()) {
return matcher.group(1);
}
} catch (Exception ignored) {}
log.trace("[{}][{}] Failed to match device name using [{}] from CN: [{}]", profile.getTenantId(), profile.getId(), regex, commonName);
return null;
}
}

View File

@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.ApiUsageState;
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.DeviceTransportType;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.OtaPackage;
@ -113,7 +114,6 @@ 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;
@ -246,7 +246,7 @@ public class DefaultTransportApiService implements TransportApiService {
return getDeviceInfo(credentials);
}
DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash);
if (deviceProfile != null && X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProvisionType())) {
if (deviceProfile != null && DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProvisionType())) {
String updatedDeviceProvisionSecret = chain.get(0);
ProvisionRequest provisionRequest = createProvisionRequest(updatedDeviceProvisionSecret);
try {
@ -259,7 +259,7 @@ public class DefaultTransportApiService implements TransportApiService {
return getEmptyTransportApiResponseFuture();
}
} else if (deviceProfile != null) {
log.warn("[{}] Device Profile provision configuration mismatched: expected {}, actual {}", deviceProfile.getId(), X509_CERTIFICATE_CHAIN, deviceProfile.getProvisionType());
log.warn("[{}][{}] Device Profile provision configuration mismatched: expected {}, actual {}", deviceProfile.getTenantId(), deviceProfile.getId(), DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN, deviceProfile.getProvisionType());
}
}
return getEmptyTransportApiResponseFuture();

View File

@ -0,0 +1,267 @@
/**
* 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.device.provision;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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.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.Tenant;
import org.thingsboard.server.common.data.device.credentials.ProvisionDeviceCredentialsData;
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.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.TenantId;
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.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.DeviceProfileService;
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.gen.transport.TransportProtos;
import org.thingsboard.server.queue.TbQueueProducer;
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.service.device.DeviceProvisionServiceImpl;;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 = DeviceProvisionServiceImpl.class)
public class DeviceProvisionServiceTest {
@MockBean
protected TbQueueProducerProvider producerProvider;
@MockBean
protected TbQueueProducer<TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> ruleEngineMsgProducer;
@MockBean
protected TbClusterService clusterService;
@MockBean
protected DeviceProfileService deviceProfileService;
@MockBean
protected DeviceService deviceService;
@MockBean
protected DeviceCredentialsService deviceCredentialsService;
@MockBean
protected AttributesService attributesService;
@MockBean
protected AuditLogService auditLogService;
@MockBean
protected PartitionService partitionService;
@SpyBean
DeviceProvisionServiceImpl service;
private String[] chain;
@Before
public void setUp() {
String filePath = "src/test/resources/provision/x509ChainProvisionTest.pem";
try {
String certificateChain = Files.readString(Paths.get(filePath));
certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain);
chain = fetchLeafCertificateFromChain(certificateChain);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
public void provisionDeviceViaX509Certificate() {
var tenant = createTenant();
var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], true);
var device = createDevice(tenant.getId(), deviceProfile.getId());
when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device);
var deviceCredentials = createDeviceCredentials(chain[0], device.getId());
when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials);
when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials);
ProvisionResponse response = service.provisionDeviceViaX509Chain(deviceProfile, createProvisionRequest(chain[0]));
verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any());
verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any());
verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any());
Assertions.assertThat(response.getResponseStatus()).isEqualTo(ProvisionResponseStatus.SUCCESS);
Assertions.assertThat(response.getDeviceCredentials()).isEqualTo(deviceCredentials);
}
@Test
public void provisionDeviceWithIncorrectConfiguration() {
var tenant = createTenant();
var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], false);
Assertions.assertThatThrownBy(() ->
service.provisionDeviceViaX509Chain(deviceProfile, createProvisionRequest(chain[0])))
.isInstanceOf(ProvisionFailedException.class);
verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any());
}
@Test
public void matchDeviceNameFromX509CNCertificateByRegex() {
var tenant = createTenant();
var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], true);
X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration();
String CN = getCNFromX509Certificate(chain[0]);
String deviceName = service.extractDeviceNameFromCNByRegEx(deviceProfile, CN, configuration.getCertificateRegExPattern());
Assertions.assertThat(deviceName).isNotBlank();
Assertions.assertThat(deviceName).isEqualTo("deviceCertificate");
}
@Test
public void matchDeviceNameFromCNByRegex() {
var CN = "DeviceA.company.com";
var regex = "(.*)\\.company.com";
var result = service.extractDeviceNameFromCNByRegEx(null, CN, regex);
Assertions.assertThat(result).isNotBlank();
Assertions.assertThat(result).isEqualTo("DeviceA");
CN = "DeviceA@company.com";
regex = "(.*)@company.com";
result = service.extractDeviceNameFromCNByRegEx(null, CN, regex);
Assertions.assertThat(result).isNotBlank();
Assertions.assertThat(result).isEqualTo("DeviceA");
CN = "prefixDeviceAsuffix@company.com";
regex = "prefix(.*)suffix@company.com";
result = service.extractDeviceNameFromCNByRegEx(null, CN, regex);
Assertions.assertThat(result).isNotBlank();
Assertions.assertThat(result).isEqualTo("DeviceA");
CN = "prefixDeviceAsufix@company.com";
regex = "prefix(.*)sufix@company.com";
result = service.extractDeviceNameFromCNByRegEx(null, CN, regex);
Assertions.assertThat(result).isNotBlank();
Assertions.assertThat(result).isEqualTo("DeviceA");
CN = "region.DeviceA.220423@company.com";
regex = "\\D+\\.(.*)\\.\\d+@company.com";
result = service.extractDeviceNameFromCNByRegEx(null, CN, regex);
Assertions.assertThat(result).isNotBlank();
Assertions.assertThat(result).isEqualTo("DeviceA");
}
private DeviceProfile createDeviceProfile(TenantId tenantId, String certificateValue, boolean isAllowToCreateNewDevices) {
X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration();
provision.setProvisionDeviceSecret(certificateValue);
provision.setCertificateRegExPattern("([^@]+)");
provision.setAllowCreateNewDevicesByX509Certificate(isAllowToCreateNewDevices);
DeviceProfileData deviceProfileData = new DeviceProfileData();
deviceProfileData.setProvisionConfiguration(provision);
DeviceProfile deviceProfile = new DeviceProfile();
deviceProfile.setId(new DeviceProfileId(UUID.randomUUID()));
deviceProfile.setProfileData(deviceProfileData);
deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue));
deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN);
deviceProfile.setTenantId(tenantId);
return deviceProfile;
}
private Device createDevice(TenantId tenantId, DeviceProfileId deviceProfileId) {
Device device = new Device();
device.setTenantId(tenantId);
device.setId(new DeviceId(UUID.randomUUID()));
device.setDeviceProfileId(deviceProfileId);
device.setCustomerId(new CustomerId(UUID.randomUUID()));
return device;
}
private Tenant createTenant() {
Tenant tenant = new Tenant();
tenant.setId(new TenantId(UUID.randomUUID()));
return tenant;
}
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 ProvisionRequest createProvisionRequest(String certificateValue) {
return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE,
new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue),
null);
}
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<String> 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]);
}
private String getCNFromX509Certificate(String x509Value) {
try {
return SslUtil.parseCommonName(SslUtil.readCertFile(x509Value));
} catch (Exception e) {
return null;
}
}
}

View File

@ -109,7 +109,8 @@ public class DefaultTransportApiServiceTest {
@Before
public void setUp() {
String filePath = "src/test/resources/mqtt/x509ChainProvisionTest.pem";
String filePath = "src/test/resources/provision/x509ChainProvisionTest.pem";
try {
certificateChain = Files.readString(Paths.get(filePath));
certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain);
@ -120,7 +121,7 @@ public class DefaultTransportApiServiceTest {
}
@Test
public void validateExistingDeviceX509Certificate() {
public void validateExistingDeviceByX509CertificateStrategy() {
var device = createDevice();
when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device));
@ -145,13 +146,13 @@ public class DefaultTransportApiServiceTest {
when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials);
var provisionResponse = createProvisionResponse(deviceCredentials);
when(deviceProvisionService.provisionDevice(any())).thenReturn(provisionResponse);
when(deviceProvisionService.provisionDeviceViaX509Chain(any(), 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());
verify(deviceProvisionService, times(1)).provisionDeviceViaX509Chain(any(), any());
}
private DeviceProfile createDeviceProfile(String certificateValue) {

View File

@ -24,5 +24,5 @@ public interface DeviceProvisionService {
ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) throws ProvisionFailedException;
ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile deviceProfile, ProvisionRequest provisionRequest);
ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile deviceProfile, ProvisionRequest provisionRequest) throws ProvisionFailedException;
}

View File

@ -408,8 +408,8 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator<D
return true;
}
}
} catch (Exception ignored) {
log.trace("Failed to validate certificate due to: ", ignored);
} catch (Exception e) {
log.trace("Failed to validate certificate due to: ", e);
}
return false;
}

View File

@ -1,15 +1,21 @@
#### Examples of RegEx usage
* **Pattern:** <code>.*</code> - matches any character (until line terminators)
<br>**CN sample:** <code>DeviceName\nAdditionalInfo</code>
<br>**Pattern matches:** <code>DeviceName</code>
The regular expression is required to extract device name from the X509 certificate's common name.
The regular expression syntax is based on Java [Pattern](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/regex/Pattern.html).
You may also use this [resource](https://regex101.com/) to test your expressions but make sure you select Java 8 flavor.
* **Pattern:** <code>^([^@]+)</code> - matches any string that starts with one or more characters that are not the <code>@</code> symbol (<code>@</code> could be replaced by any other symbol)
<br>**CN sample:** <code>DeviceName@AdditionalInfo</code>
<br>**Pattern matches:** <code>DeviceName</code>
* **Pattern:**<code>(.*)\.company.com</code>- matches any characters before the ".company.com".
<br>**CN sample:**<code>DeviceA.company.com</code>
<br>**Result:**<code>DeviceA</code>
* **Pattern:** <code>[\w]*$</code> (equivalent to <code>[a-zA-Z0-9_]\*$</code>) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
<br>**CN sample:** <code>AdditionalInfo2110#DeviceName_01</code>
<br>**Pattern matches:** <code>DeviceName_01</code>
* **Pattern:** <code>(.*)@company.com</code>- matches any characters before the "@company.com".
<br>**CN sample:**<code>DeviceA@company.com</code>
<br>**Result:**<code>DeviceA</code>
**Note:** Client will get error response in case regex is failed to match.
* **Pattern:** <code>prefix(.*)suffix@company.com</code>- matches characters between "prefix" and "suffix@company.com".
<br>**CN sample:**<code>prefixDeviceAsuffix@company.com</code>
<br>**Pattern matches:** <code>DeviceA</code>
* **Pattern:** <code>\\D+\\.(.*)\\.\\d+@company.com</code>- matches characters between not digits prefix followed by period and sequence of digits with "@company.com" ending.
<br>**CN sample:**<code>region.DeviceA.220423@company.com</code>
<br>**Pattern matches:** <code>DeviceA</code>