Compare commits
No commits in common. "a9d8782a27a2e041228c8e2ef3cbb2a2466c5ae0" and "2a02e26b013803da1b8587ca67e6e3988ced23ab" have entirely different histories.
a9d8782a27
...
2a02e26b01
6 changed files with 91 additions and 111 deletions
|
@ -3,12 +3,13 @@ package de.mlessmann.certassist.models;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.proxy.HibernateProxy;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.*;
|
|
||||||
import org.hibernate.proxy.HibernateProxy;
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "fingerprint" }) })
|
@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "fingerprint" }) })
|
||||||
|
@ -29,14 +30,7 @@ public class Certificate {
|
||||||
|
|
||||||
private String trustingAuthority;
|
private String trustingAuthority;
|
||||||
|
|
||||||
/**
|
@Min(1)
|
||||||
* <ul>
|
|
||||||
* <li>-1 = no requested key length is known (might happen with imported certificates)</li>
|
|
||||||
* <li>0 = no key is available for this certificate (might happen with trusted third party certificates)</li>
|
|
||||||
* <li>> 1 = The key length in bits used for the private key of this certificate</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@Min(-1)
|
|
||||||
private int requestedKeyLength;
|
private int requestedKeyLength;
|
||||||
|
|
||||||
private OffsetDateTime notBefore;
|
private OffsetDateTime notBefore;
|
||||||
|
@ -75,12 +69,8 @@ public class Certificate {
|
||||||
public final boolean equals(Object o) {
|
public final boolean equals(Object o) {
|
||||||
if (this == o) return true;
|
if (this == o) return true;
|
||||||
if (o == null) return false;
|
if (o == null) return false;
|
||||||
Class<?> oEffectiveClass = o instanceof HibernateProxy
|
Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
|
||||||
? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
|
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
|
||||||
: o.getClass();
|
|
||||||
Class<?> thisEffectiveClass = this instanceof HibernateProxy
|
|
||||||
? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass()
|
|
||||||
: this.getClass();
|
|
||||||
if (thisEffectiveClass != oEffectiveClass) return false;
|
if (thisEffectiveClass != oEffectiveClass) return false;
|
||||||
Certificate that = (Certificate) o;
|
Certificate that = (Certificate) o;
|
||||||
return getId() != null && Objects.equals(getId(), that.getId());
|
return getId() != null && Objects.equals(getId(), that.getId());
|
||||||
|
@ -88,8 +78,6 @@ public class Certificate {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final int hashCode() {
|
public final int hashCode() {
|
||||||
return this instanceof HibernateProxy
|
return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
|
||||||
? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode()
|
|
||||||
: getClass().hashCode();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,9 +23,11 @@ import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.time.format.DateTimeFormatterBuilder;
|
import java.time.format.DateTimeFormatterBuilder;
|
||||||
import java.time.temporal.ChronoField;
|
import java.time.temporal.ChronoField;
|
||||||
|
import java.time.temporal.IsoFields;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
@ -70,20 +72,20 @@ public class OpenSSLService {
|
||||||
"^(?<algo>[0-9a-zA-Z]+) (?i)Fingerprint(?-i)=(?<finger>[a-z:A-Z0-9]+)"
|
"^(?<algo>[0-9a-zA-Z]+) (?i)Fingerprint(?-i)=(?<finger>[a-z:A-Z0-9]+)"
|
||||||
);
|
);
|
||||||
private final DateTimeFormatter OSSL_DATE_TIME = new DateTimeFormatterBuilder()
|
private final DateTimeFormatter OSSL_DATE_TIME = new DateTimeFormatterBuilder()
|
||||||
.parseCaseInsensitive()
|
.parseCaseInsensitive()
|
||||||
.appendValue(ChronoField.YEAR, 4)
|
.appendValue(ChronoField.YEAR, 4)
|
||||||
.appendLiteral('-')
|
.appendLiteral('-')
|
||||||
.appendValue(ChronoField.MONTH_OF_YEAR, 2)
|
.appendValue(ChronoField.MONTH_OF_YEAR, 2)
|
||||||
.appendLiteral('-')
|
.appendLiteral('-')
|
||||||
.appendValue(ChronoField.DAY_OF_MONTH, 2)
|
.appendValue(ChronoField.DAY_OF_MONTH, 2)
|
||||||
.appendLiteral(' ')
|
.appendLiteral(' ')
|
||||||
.appendValue(ChronoField.HOUR_OF_DAY, 2)
|
.appendValue(ChronoField.HOUR_OF_DAY, 2)
|
||||||
.appendLiteral(':')
|
.appendLiteral(':')
|
||||||
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
|
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
|
||||||
.appendLiteral(':')
|
.appendLiteral(':')
|
||||||
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
|
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
|
||||||
.appendOffset("+HH:MM:ss", "Z")
|
.appendOffset("+HH:MM:ss","Z")
|
||||||
.toFormatter();
|
.toFormatter();
|
||||||
private static final String OSSL_ENV_KEY_PW = "KEY_PASS";
|
private static final String OSSL_ENV_KEY_PW = "KEY_PASS";
|
||||||
private static final String OSSL_ARG_KEY_PW = "env:" + OSSL_ENV_KEY_PW;
|
private static final String OSSL_ARG_KEY_PW = "env:" + OSSL_ENV_KEY_PW;
|
||||||
private final AtomicBoolean versionLogged = new AtomicBoolean(false);
|
private final AtomicBoolean versionLogged = new AtomicBoolean(false);
|
||||||
|
@ -762,7 +764,7 @@ public class OpenSSLService {
|
||||||
"sep_multiline",
|
"sep_multiline",
|
||||||
"-nameopt",
|
"-nameopt",
|
||||||
"lname",
|
"lname",
|
||||||
"-modulus"
|
"-modulus"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
command.addAll(Arrays.asList(additArgs));
|
command.addAll(Arrays.asList(additArgs));
|
||||||
|
|
|
@ -2,21 +2,23 @@ package de.mlessmann.certassist.openssl;
|
||||||
|
|
||||||
import de.mlessmann.certassist.models.CertificateInfoExtension;
|
import de.mlessmann.certassist.models.CertificateInfoExtension;
|
||||||
import de.mlessmann.certassist.models.CertificateInfoSubject;
|
import de.mlessmann.certassist.models.CertificateInfoSubject;
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
public record X509CertificateInfo(
|
public record X509CertificateInfo(
|
||||||
CertificateInfoSubject subject,
|
CertificateInfoSubject subject,
|
||||||
@Nullable CertificateInfoSubject issuer,
|
@Nullable CertificateInfoSubject issuer,
|
||||||
String serial,
|
String serial,
|
||||||
OffsetDateTime notBefore,
|
OffsetDateTime notBefore,
|
||||||
OffsetDateTime notAfter,
|
OffsetDateTime notAfter,
|
||||||
List<CertificateInfoExtension> extensions
|
List<CertificateInfoExtension> extensions
|
||||||
) {
|
) {
|
||||||
public X509CertificateInfo {
|
public X509CertificateInfo {
|
||||||
Objects.requireNonNull(subject);
|
Objects.requireNonNull(subject);
|
||||||
|
@ -32,8 +34,4 @@ public record X509CertificateInfo(
|
||||||
}
|
}
|
||||||
return Collections.unmodifiableList(extensions);
|
return Collections.unmodifiableList(extensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasExtensions() {
|
|
||||||
return extensions != null && !extensions.isEmpty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,17 +79,9 @@ public class CertificateCreationService {
|
||||||
|
|
||||||
private Certificate createEntityFromInfo(X509CertificateInfo info) {
|
private Certificate createEntityFromInfo(X509CertificateInfo info) {
|
||||||
final Certificate certificate = new Certificate();
|
final Certificate certificate = new Certificate();
|
||||||
certificate.setType(
|
certificate.setType(mapCertificateRequestType(info.issuer() != null ? CertificateInfo.RequestType.NORMAL_CERTIFICATE : CertificateInfo.RequestType.STANDALONE_CERTIFICATE));
|
||||||
mapCertificateRequestType(
|
|
||||||
info.issuer() != null
|
|
||||||
? CertificateInfo.RequestType.NORMAL_CERTIFICATE
|
|
||||||
: CertificateInfo.RequestType.STANDALONE_CERTIFICATE
|
|
||||||
)
|
|
||||||
);
|
|
||||||
certificate.setSubjectCommonName(info.subject().getCommonName());
|
certificate.setSubjectCommonName(info.subject().getCommonName());
|
||||||
if (info.issuer() != null) {
|
certificate.setTrustingAuthority(info.issuer().getCommonName());
|
||||||
certificate.setTrustingAuthority(info.issuer().getCommonName());
|
|
||||||
}
|
|
||||||
certificate.setRequestedKeyLength(-1);
|
certificate.setRequestedKeyLength(-1);
|
||||||
certificate.setNotBefore(info.notBefore());
|
certificate.setNotBefore(info.notBefore());
|
||||||
certificate.setNotAfter(info.notAfter());
|
certificate.setNotAfter(info.notAfter());
|
||||||
|
@ -102,14 +94,12 @@ public class CertificateCreationService {
|
||||||
certificate.setSubjectState(subjectInfo.getState());
|
certificate.setSubjectState(subjectInfo.getState());
|
||||||
certificate.setSubjectLocality(subjectInfo.getLocality());
|
certificate.setSubjectLocality(subjectInfo.getLocality());
|
||||||
|
|
||||||
if (info.hasExtensions()) {
|
final CertificateInfoExtension extension = info.extensions().getFirst();
|
||||||
final CertificateInfoExtension extension = info.extensions().getFirst();
|
if (extension != null) {
|
||||||
if (extension != null) {
|
final CertificateExtension certificateExtension = new CertificateExtension();
|
||||||
final CertificateExtension certificateExtension = new CertificateExtension();
|
certificateExtension.setIdentifier("alternativeNames");
|
||||||
certificateExtension.setIdentifier("alternativeNames");
|
certificateExtension.setValue(String.join(",", extension.getAlternativeDnsNames()));
|
||||||
certificateExtension.setValue(String.join(",", extension.getAlternativeDnsNames()));
|
certificate.setCertificateExtension(List.of(certificateExtension));
|
||||||
certificate.setCertificateExtension(List.of(certificateExtension));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return certificate;
|
return certificate;
|
||||||
}
|
}
|
||||||
|
@ -123,7 +113,6 @@ public class CertificateCreationService {
|
||||||
try {
|
try {
|
||||||
String fingerprint = openSSLService.getCertificateFingerprint(certificate);
|
String fingerprint = openSSLService.getCertificateFingerprint(certificate);
|
||||||
Certificate entity = createEntityFromInfo(openSSLService.getCertificateInfo(certificate));
|
Certificate entity = createEntityFromInfo(openSSLService.getCertificateInfo(certificate));
|
||||||
entity.setRequestedKeyLength(-1);
|
|
||||||
entity.setFingerprint(fingerprint);
|
entity.setFingerprint(fingerprint);
|
||||||
entity.setCert(Files.readAllBytes(certificate));
|
entity.setCert(Files.readAllBytes(certificate));
|
||||||
if (keyFile != null) {
|
if (keyFile != null) {
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
package de.mlessmann.certassist;
|
package de.mlessmann.certassist;
|
||||||
|
|
||||||
import static java.util.Objects.requireNonNull;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
import de.mlessmann.certassist.models.CertificateInfo;
|
import de.mlessmann.certassist.models.CertificateInfo;
|
||||||
import de.mlessmann.certassist.models.CertificateInfo.RequestType;
|
import de.mlessmann.certassist.models.CertificateInfo.RequestType;
|
||||||
import de.mlessmann.certassist.models.CertificateInfoExtension;
|
import de.mlessmann.certassist.models.CertificateInfoExtension;
|
||||||
|
@ -12,19 +8,24 @@ import de.mlessmann.certassist.openssl.CertificatePasswordProvider;
|
||||||
import de.mlessmann.certassist.openssl.CertificateProvider;
|
import de.mlessmann.certassist.openssl.CertificateProvider;
|
||||||
import de.mlessmann.certassist.openssl.OpenSSLService;
|
import de.mlessmann.certassist.openssl.OpenSSLService;
|
||||||
import de.mlessmann.certassist.service.ExecutableResolver;
|
import de.mlessmann.certassist.service.ExecutableResolver;
|
||||||
import java.nio.file.Path;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static java.util.Objects.requireNonNull;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
class TestOpenSSLService {
|
class TestOpenSSLService {
|
||||||
|
|
||||||
public static final String TEST_CERT_PASSPHRASE = "ABC-123";
|
public static final String TEST_CERT_PASSPHRASE = "ABC-123";
|
||||||
public static final Path TEST_CERT_PATH = Path.of("src/test/resources/openssl");
|
public static final Path TEST_CERT_PATH = Path.of("src/test/resources/openssl");
|
||||||
public static final String TEST_CERT_FINGERPRINT =
|
public static final String TEST_CERT_FINGERPRINT =
|
||||||
"SHA1;4E:D6:0A:47:F0:63:AD:96:26:83:16:28:32:F5:E8:36:5A:62:91:95";
|
"SHA1;4E:D6:0A:47:F0:63:AD:96:26:83:16:28:32:F5:E8:36:5A:62:91:95";
|
||||||
private static final String ERR_NOT_ENCRYPTED = "Private key not encrypted";
|
private static final String ERR_NOT_ENCRYPTED = "Private key not encrypted";
|
||||||
private static final String ERR_VERIFY_FAILED = "Certificate verification failed";
|
private static final String ERR_VERIFY_FAILED = "Certificate verification failed";
|
||||||
|
|
||||||
|
@ -44,55 +45,57 @@ class TestOpenSSLService {
|
||||||
var certificateCreator = new OpenSSLService(executableResolver, passwordProvider, certificateProvider);
|
var certificateCreator = new OpenSSLService(executableResolver, passwordProvider, certificateProvider);
|
||||||
|
|
||||||
CertificateInfo certRequest = CertificateInfo
|
CertificateInfo certRequest = CertificateInfo
|
||||||
.builder()
|
.builder()
|
||||||
.type(RequestType.STANDALONE_CERTIFICATE)
|
.type(RequestType.STANDALONE_CERTIFICATE)
|
||||||
.subject(
|
.subject(
|
||||||
CertificateInfoSubject
|
CertificateInfoSubject
|
||||||
.builder()
|
.builder()
|
||||||
.commonName("test.home")
|
.commonName("test.home")
|
||||||
.country("DE")
|
.country("DE")
|
||||||
.state("SH")
|
.state("SH")
|
||||||
.locality("HH")
|
.locality("HH")
|
||||||
.organization("Crazy-Cats")
|
.organization("Crazy-Cats")
|
||||||
)
|
)
|
||||||
.extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.home", "test3.home"))
|
.extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.home", "test3.home"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
try (var cert = certificateCreator.createCertificate(certRequest)) {
|
try (var cert = certificateCreator.createCertificate(certRequest)) {
|
||||||
assertThat(certificateCreator.verifyCertificate(cert.certificatePath(), cert.certificatePath()))
|
assertThat(certificateCreator.verifyCertificate(cert.certificatePath(), cert.certificatePath()))
|
||||||
.withFailMessage(ERR_VERIFY_FAILED)
|
.withFailMessage(ERR_VERIFY_FAILED)
|
||||||
.isTrue();
|
.isTrue();
|
||||||
assertThat(certificateCreator.isKeyEncrypted(requireNonNull(cert.certificateKeyPath())))
|
assertThat(certificateCreator.isKeyEncrypted(requireNonNull(cert.certificateKeyPath())))
|
||||||
.withFailMessage(ERR_NOT_ENCRYPTED)
|
.withFailMessage(ERR_NOT_ENCRYPTED)
|
||||||
.isTrue();
|
.isTrue();
|
||||||
|
|
||||||
CertificateInfo childRequest = CertificateInfo
|
CertificateInfo childRequest = CertificateInfo
|
||||||
.builder()
|
.builder()
|
||||||
.type(RequestType.NORMAL_CERTIFICATE)
|
.type(RequestType.NORMAL_CERTIFICATE)
|
||||||
.trustingAuthority(cert.fingerprint())
|
.trustingAuthority(cert.fingerprint())
|
||||||
.subject(
|
.subject(
|
||||||
CertificateInfoSubject
|
CertificateInfoSubject
|
||||||
.builder()
|
.builder()
|
||||||
.commonName("test.local")
|
.commonName("test.local")
|
||||||
.country("DE")
|
.country("DE")
|
||||||
.state("SH")
|
.state("SH")
|
||||||
.locality("HH")
|
.locality("HH")
|
||||||
.organization("Crazy-Cats")
|
.organization("Crazy-Cats")
|
||||||
)
|
)
|
||||||
.extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.local", "test3.local"))
|
.extension(CertificateInfoExtension.builder().alternativeDnsNames("test2.local", "test3.local"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
var spiedCert = spy(cert);
|
var spiedCert = spy(cert);
|
||||||
doNothing().when(spiedCert).close();
|
doNothing().when(spiedCert).close();
|
||||||
when(certificateProvider.requestCertificateUsage(cert.fingerprint())).thenReturn(spiedCert);
|
when(certificateProvider.requestCertificateUsage(cert.fingerprint())).thenReturn(spiedCert);
|
||||||
try (var childCert = certificateCreator.createCertificate(childRequest)) {
|
try (var childCert = certificateCreator.createCertificate(childRequest)) {
|
||||||
Path fullchain = childCert.fullchainPath();
|
Path fullchain = childCert.fullchainPath();
|
||||||
assertThat(certificateCreator.verifyCertificate(requireNonNull(fullchain), cert.certificatePath()))
|
assertThat(
|
||||||
.withFailMessage(ERR_VERIFY_FAILED)
|
certificateCreator.verifyCertificate(requireNonNull(fullchain), cert.certificatePath())
|
||||||
.isTrue();
|
)
|
||||||
|
.withFailMessage(ERR_VERIFY_FAILED)
|
||||||
|
.isTrue();
|
||||||
assertThat(certificateCreator.isKeyEncrypted(requireNonNull(childCert.certificateKeyPath())))
|
assertThat(certificateCreator.isKeyEncrypted(requireNonNull(childCert.certificateKeyPath())))
|
||||||
.withFailMessage(ERR_NOT_ENCRYPTED)
|
.withFailMessage(ERR_NOT_ENCRYPTED)
|
||||||
.isTrue();
|
.isTrue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,7 +124,6 @@ class TestOpenSSLService {
|
||||||
assertThat(request.subject().getState()).isEqualTo("SH");
|
assertThat(request.subject().getState()).isEqualTo("SH");
|
||||||
assertThat(request.subject().getLocality()).isEqualTo("HH");
|
assertThat(request.subject().getLocality()).isEqualTo("HH");
|
||||||
assertThat(request.subject().getOrganization()).isEqualTo("Crazy-Cats");
|
assertThat(request.subject().getOrganization()).isEqualTo("Crazy-Cats");
|
||||||
assertThat(request.extensions().getFirst().getAlternativeDnsNames())
|
assertThat(request.extensions().getFirst().getAlternativeDnsNames()).containsExactly("test2.local", "test3.local");
|
||||||
.containsExactly("test2.local", "test3.local");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import de.mlessmann.certassist.models.Certificate;
|
||||||
import de.mlessmann.certassist.models.CertificateExtension;
|
import de.mlessmann.certassist.models.CertificateExtension;
|
||||||
import de.mlessmann.certassist.models.CertificateType;
|
import de.mlessmann.certassist.models.CertificateType;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.StreamSupport;
|
import java.util.stream.StreamSupport;
|
||||||
|
|
Loading…
Add table
Reference in a new issue