diff --git a/core/src/main/java/org/eclipse/hono/util/CredentialsConstants.java b/core/src/main/java/org/eclipse/hono/util/CredentialsConstants.java index c74ac03e7e..50ce9219b9 100644 --- a/core/src/main/java/org/eclipse/hono/util/CredentialsConstants.java +++ b/core/src/main/java/org/eclipse/hono/util/CredentialsConstants.java @@ -39,6 +39,11 @@ public final class CredentialsConstants extends RequestResponseApiConstants { * The name of the field that contains the authentication identifier. */ public static final String FIELD_AUTH_ID = "auth-id"; + /** + * The name of the field that contains the generated authentication identifier + * by applying the template to the subject DN. + */ + public static final String FIELD_GENERATED_AUTH_ID = "generated-auth-id"; /** * The name of the field that contains the secret(s) of the credentials. */ diff --git a/core/src/main/java/org/eclipse/hono/util/RegistryManagementConstants.java b/core/src/main/java/org/eclipse/hono/util/RegistryManagementConstants.java index f34d593530..a36df94002 100644 --- a/core/src/main/java/org/eclipse/hono/util/RegistryManagementConstants.java +++ b/core/src/main/java/org/eclipse/hono/util/RegistryManagementConstants.java @@ -180,6 +180,15 @@ public final class RegistryManagementConstants extends RequestResponseApiConstan * The name of the field that contains the authentication identifier. */ public static final String FIELD_AUTH_ID = "auth-id"; + /** + * The name of the field that contains the generated authentication identifier + * by applying the template to the subject DN. + */ + public static final String FIELD_GENERATED_AUTH_ID = "generated-auth-id"; + /** + * The name of the field that contains the issuer DN of the client certificate. + */ + public static final String FIELD_ISSUER_DN = "issuer-dn"; /** * The name of the field that contains the secret(s) of the credentials. */ diff --git a/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableManagementStore.java b/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableManagementStore.java index 8dc2c481d2..d111d1931e 100644 --- a/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableManagementStore.java +++ b/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableManagementStore.java @@ -39,6 +39,7 @@ import org.eclipse.hono.service.management.device.Device; import org.eclipse.hono.service.management.tenant.Tenant; import org.eclipse.hono.tracing.TracingHelper; +import org.eclipse.hono.util.RegistryManagementConstants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -162,6 +163,7 @@ public TableManagementStore(final SQLClient client, final Tracer tracer, final S "device_id", "type", "auth_id", + "generated_auth_id", "data"); this.deleteAllCredentialsStatement = cfg @@ -692,8 +694,9 @@ public Future> setCredentials( .expand(map -> { map.put("tenant_id", key.getTenantId()); map.put("device_id", key.getDeviceId()); - map.put("type", c.getString("type")); - map.put("auth_id", c.getString("auth-id")); + map.put("type", c.getString(RegistryManagementConstants.FIELD_TYPE)); + map.put("auth_id", c.getString(RegistryManagementConstants.FIELD_AUTH_ID)); + map.put("generated_auth_id", c.getString(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID)); map.put("data", c.toString()); }) .trace(this.tracer, span.context()) diff --git a/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.postgresql.sql.yaml b/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.postgresql.sql.yaml index b77fd7ab04..e5176e0836 100644 --- a/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.postgresql.sql.yaml +++ b/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.postgresql.sql.yaml @@ -35,12 +35,14 @@ insertCredentialEntry: | device_id, type, auth_id, + generated_auth_id, data ) VALUES ( :tenant_id, :device_id, :type, :auth_id, + :generated_auth_id, :data::jsonb ) diff --git a/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.sql.yaml b/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.sql.yaml index bc61e2babe..5124ae31fc 100644 --- a/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.sql.yaml +++ b/services/base-jdbc/src/main/resources/org/eclipse/hono/service/base/jdbc/store/device/base.sql.yaml @@ -102,7 +102,7 @@ findCredentials: | WHERE tenant_id=:tenant_id AND - auth_id=:auth_id + COALESCE(generated_auth_id, auth_id)=:auth_id AND type=:type @@ -112,12 +112,14 @@ insertCredentialEntry: | device_id, type, auth_id, + generated_auth_id, data ) VALUES ( :tenant_id, :device_id, :type, :auth_id, + :generated_auth_id, :data ) diff --git a/services/base-jdbc/src/main/resources/sql/h2/02-create.devices.sql b/services/base-jdbc/src/main/resources/sql/h2/02-create.devices.sql index bafd66eea2..8741d6ddce 100644 --- a/services/base-jdbc/src/main/resources/sql/h2/02-create.devices.sql +++ b/services/base-jdbc/src/main/resources/sql/h2/02-create.devices.sql @@ -20,8 +20,9 @@ CREATE TABLE IF NOT EXISTS device_credentials TENANT_ID VARCHAR(256) NOT NULL, DEVICE_ID VARCHAR(256) NOT NULL, - TYPE VARCHAR(64) NOT NULL, - AUTH_ID VARCHAR(256) NOT NULL, + TYPE VARCHAR(64) NOT NULL, + AUTH_ID VARCHAR(256) NOT NULL, + GENERATED_AUTH_ID VARCHAR(256), DATA TEXT, diff --git a/services/base-jdbc/src/main/resources/sql/postgresql/02-create.devices.sql b/services/base-jdbc/src/main/resources/sql/postgresql/02-create.devices.sql index 371dcc12e7..ac6efa1d64 100644 --- a/services/base-jdbc/src/main/resources/sql/postgresql/02-create.devices.sql +++ b/services/base-jdbc/src/main/resources/sql/postgresql/02-create.devices.sql @@ -20,8 +20,9 @@ CREATE TABLE IF NOT EXISTS device_credentials TENANT_ID VARCHAR(36) NOT NULL, DEVICE_ID VARCHAR(256) NOT NULL, - TYPE VARCHAR(64) NOT NULL, - AUTH_ID VARCHAR(256) NOT NULL, + TYPE VARCHAR(64) NOT NULL, + AUTH_ID VARCHAR(256) NOT NULL, + GENERATED_AUTH_ID VARCHAR(256), DATA JSONB, diff --git a/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/service/credentials/AbstractCredentialsManagementService.java b/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/service/credentials/AbstractCredentialsManagementService.java index 2c4aadbdd2..1752f228cc 100644 --- a/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/service/credentials/AbstractCredentialsManagementService.java +++ b/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/service/credentials/AbstractCredentialsManagementService.java @@ -20,6 +20,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.eclipse.hono.auth.HonoPasswordEncoder; import org.eclipse.hono.client.ClientErrorException; @@ -35,6 +36,9 @@ import org.eclipse.hono.service.management.credentials.CommonCredential; import org.eclipse.hono.service.management.credentials.CredentialsManagementService; import org.eclipse.hono.service.management.credentials.PasswordCredential; +import org.eclipse.hono.service.management.credentials.X509CertificateCredential; +import org.eclipse.hono.service.management.credentials.X509CertificateCredentialWithGeneratedAuthId; +import org.eclipse.hono.service.management.tenant.Tenant; import org.eclipse.hono.util.Futures; import org.eclipse.hono.util.Lifecycle; import org.eclipse.hono.util.Strings; @@ -177,10 +181,11 @@ public final Future> updateCredentials( Objects.requireNonNull(resourceVersion); Objects.requireNonNull(span); - return this.tenantInformationService - .getTenant(tenantId, span) + final Future tenantFuture = tenantInformationService.getTenant(tenantId, span); + return tenantFuture .compose(tenant -> tenant.checkCredentialsLimitExceeded(tenantId, credentials)) - .compose(ok -> verifyAndEncodePasswords(credentials)) + .compose(ok -> applyAuthIdTemplateForX509CertificateCredentials(tenantFuture.result(), credentials)) + .compose(this::verifyAndEncodePasswords) .compose(encodedCredentials -> processUpdateCredentials( DeviceKey.from(tenantId, deviceId), encodedCredentials, @@ -279,4 +284,18 @@ protected List checkCredentials(final List c return credentials; } + private static Future> applyAuthIdTemplateForX509CertificateCredentials( + final Tenant tenant, + final List credentials) { + final List creds = credentials.stream() + .map(cred -> { + if (cred instanceof X509CertificateCredential) { + return X509CertificateCredentialWithGeneratedAuthId.applyAuthIdTemplate( + (X509CertificateCredential) cred, tenant); + } + return cred; + }) + .collect(Collectors.toUnmodifiableList()); + return Future.succeededFuture(creds); + } } diff --git a/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/util/DeviceRegistryUtils.java b/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/util/DeviceRegistryUtils.java index fce48b0730..26c37f3ca7 100644 --- a/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/util/DeviceRegistryUtils.java +++ b/services/device-registry-base/src/main/java/org/eclipse/hono/deviceregistry/util/DeviceRegistryUtils.java @@ -70,7 +70,8 @@ private DeviceRegistryUtils() { */ public static Future mapError(final Throwable error, final String tenantId) { if (error instanceof IllegalArgumentException) { - return Future.failedFuture(new ClientErrorException(tenantId, HttpURLConnection.HTTP_BAD_REQUEST, error.getMessage())); + return Future.failedFuture( + new ClientErrorException(tenantId, HttpURLConnection.HTTP_BAD_REQUEST, error.getMessage(), error)); } return Future.failedFuture(error); } diff --git a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/Credentials.java b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/Credentials.java index 4680d076fe..f6dd700f33 100644 --- a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/Credentials.java +++ b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/Credentials.java @@ -42,6 +42,16 @@ public static PskCredential createPSKCredential(final String authId, final Strin return new PskCredential(authId, List.of(s)); } + /** + * Creates a X509 certificate based credential from the given subject DN. + * + * @param subjectDN The subject DN. + * @return The X509 certificate credential. + */ + public static X509CertificateCredential createX509CertificateCredential(final String subjectDN) { + return X509CertificateCredential.fromSubjectDn(subjectDN, List.of(new X509CertificateSecret())); + } + /** * Creates a password type based credential containing a hashed password secret. * diff --git a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/DelegatingCredentialsManagementHttpEndpoint.java b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/DelegatingCredentialsManagementHttpEndpoint.java index c912b7c4be..e46e66793f 100644 --- a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/DelegatingCredentialsManagementHttpEndpoint.java +++ b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/DelegatingCredentialsManagementHttpEndpoint.java @@ -27,6 +27,7 @@ import org.eclipse.hono.service.management.OperationResult; import org.eclipse.hono.tracing.TracingHelper; import org.eclipse.hono.util.RegistryManagementConstants; +import org.eclipse.hono.util.Strings; import io.opentracing.Span; import io.vertx.core.CompositeFuture; @@ -207,7 +208,19 @@ private static List decodeCredentials(final JsonArray array) { return array.stream() .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) + .map(DelegatingCredentialsManagementHttpEndpoint::checkForGeneratedAuthId) .map(json -> json.mapTo(CommonCredential.class)) .collect(Collectors.toList()); } + + private static JsonObject checkForGeneratedAuthId(final JsonObject credential) { + final String type = credential.getString(RegistryManagementConstants.FIELD_TYPE); + if (!Strings.isNullOrEmpty(type) && type.equals(RegistryManagementConstants.SECRETS_TYPE_X509_CERT)) { + if (credential.containsKey(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID)) { + throw new IllegalArgumentException( + "credentials object contains an invalid attribute [generated-auth-id]"); + } + } + return credential; + } } diff --git a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredential.java b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredential.java index dc233f6bf2..9edd897bd8 100644 --- a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredential.java +++ b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredential.java @@ -20,7 +20,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Objects; -import java.util.function.Predicate; +import java.util.Optional; import javax.security.auth.x500.X500Principal; @@ -46,63 +46,96 @@ public class X509CertificateCredential extends CommonCredential { static final String TYPE = RegistryManagementConstants.SECRETS_TYPE_X509_CERT; private final List secrets = new LinkedList<>(); + private final String issuerDN; + private final String generatedAuthId; + + /** + * Creates a new credentials object from the given authentication identifier and secrets. + * + * @param authId The authentication identifier. + * @param generatedAuthId The authentication identifier generated by applying the auth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. + * @param secrets The credential's secret(s). + * @throws NullPointerException if authentication identifier and secrets are {@code null}. + */ + protected X509CertificateCredential(final String authId, final String generatedAuthId, + final List secrets) { + super(authId); + setSecrets(secrets); + this.generatedAuthId = generatedAuthId; + this.issuerDN = null; + } /** * Creates a new credentials object for an X.500 Distinguished Name. *

* The given distinguished name will be normalized to RFC 2253 format. * - * @param distinguishedName The DN to use as the authentication identifier. + * @param subjectDN The subject DN of the client certificate to be used as the authentication identifier. + * @param issuerDN The issuer DN of the client certificate. + *

It is used to retrieve the auth-id-template from the tenant's + * trust anchor to generate the authentication identifier. + * @param generatedAuthId the authentication identifier generated by applying the auth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. * @param secrets The credential's secret(s). - * @throws NullPointerException if any of the parameters are {@code null}. - * @throws IllegalArgumentException if the given string is not a valid X.500 distinguished name or if - * secrets is empty. + * @throws NullPointerException if subject DN or secrets is {@code null}. */ - private X509CertificateCredential(final String distinguishedName, final List secrets) { - super(new X500Principal(distinguishedName).getName(X500Principal.RFC2253)); + private X509CertificateCredential(final String subjectDN, final String issuerDN, final String generatedAuthId, + final List secrets) { + super(new X500Principal(subjectDN).getName(X500Principal.RFC2253)); setSecrets(secrets); + this.generatedAuthId = generatedAuthId; + this.issuerDN = issuerDN; } /** * Creates a new credentials object. *

* This method tries to decode a non-null byte array into an X.509 certificate and delegate to - * {@link #fromCertificate(X509Certificate)}. Otherwise, the {@link #fromSubjectDn(String, List)} - * method is invoked with the given distinguished name and secrets parameter values. + * {@link #fromCertificate(X509Certificate, String)}. Otherwise, the + * {@link #fromSubjectDn(String, String, String, List)} method is invoked with the given + * subject DN, issuer DN and secrets parameter values. * * @param derEncodedX509Certificate The DER encoding of the client certificate. - * @param distinguishedName The DN to use as the authentication identifier. + * @param subjectDN The subject DN of the client certificate to be used as the authentication identifier. + * @param issuerDN The issuer DN of the client certificate. + *

It is used to retrieve the auth-id-template from the tenant's + * trust anchor to generate the authentication identifier. + * @param generatedAuthId the authentication identifier generated by applying the auth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. * @param secrets The credential's secret(s). - * @throws NullPointerException if certificate bytes and any of distinguished name and secrets are {@code null}. + * @throws NullPointerException if certificate bytes and either subject DN or secrets are {@code null}. * @throws IllegalArgumentException if the given byte array cannot be decoded into an X.509 certificate or if - * the given name is not a valid X.500 distinguished name or if + * the given subject and issuer DNs are not a valid X.500 distinguished name or if * secrets is empty. * @return The credentials. */ @JsonCreator(mode = Mode.PROPERTIES) public static X509CertificateCredential fromProperties( @JsonProperty(value = RegistryManagementConstants.FIELD_PAYLOAD_CERT) final byte[] derEncodedX509Certificate, - @JsonProperty(value = RegistryManagementConstants.FIELD_AUTH_ID) final String distinguishedName, + @JsonProperty(value = RegistryManagementConstants.FIELD_AUTH_ID) final String subjectDN, + @JsonProperty(value = RegistryManagementConstants.FIELD_ISSUER_DN) final String issuerDN, + @JsonProperty(value = RegistryManagementConstants.FIELD_GENERATED_AUTH_ID) final String generatedAuthId, @JsonProperty(value = RegistryManagementConstants.FIELD_SECRETS) final List secrets) { if (derEncodedX509Certificate == null) { - Objects.requireNonNull(distinguishedName); + Objects.requireNonNull(subjectDN); Objects.requireNonNull(secrets); if (secrets.size() != 1) { throw new IllegalArgumentException("list must contain exactly one secret"); } - return fromSubjectDn(distinguishedName, secrets); + return fromSubjectDn(subjectDN, issuerDN, generatedAuthId, secrets); } else { - return fromCertificate(deserialize(derEncodedX509Certificate)); + return fromCertificate(deserialize(derEncodedX509Certificate), generatedAuthId); } } /** - * Creates a new credentials object for an X.500 Distinguished Name. + * Creates a new credentials object from the subject DN and secrets. *

- * The given distinguished name will be normalized to RFC 2253 format. + * The given subject DN will be normalized to RFC 2253 format. * - * @param distinguishedName The DN to use as the authentication identifier. + * @param subjectDN The subject DN to use as the authentication identifier. * @param secrets The credential's secret(s). * @throws NullPointerException if any of the parameters are {@code null}. * @throws IllegalArgumentException if the given string is not a valid X.500 distinguished name or if @@ -110,27 +143,68 @@ public static X509CertificateCredential fromProperties( * @return The credentials. */ public static X509CertificateCredential fromSubjectDn( - final String distinguishedName, + final String subjectDN, final List secrets) { - return new X509CertificateCredential(distinguishedName, secrets); + return fromSubjectDn(subjectDN, null, null, secrets); + } + + /** + * Creates a new credentials object from the subject DN, issuer DN, generated authentication identifier and secrets. + *

+ * The given subject DN and issuer DN will be normalized to RFC 2253 format. + * + * @param subjectDN The subject DN to use as the authentication identifier. + * @param issuerDN The issuer DN of the client certificate. + *

It is used to retrieve the auth-id-template from the tenant's + * trust anchor to generate the authentication identifier. + * @param generatedAuthId the authentication identifier generated by applying the auth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. + * @param secrets The credential's secret(s). + * @throws NullPointerException if subject DN or secrets is {@code null}. + * @throws IllegalArgumentException if the given subject DN or issuer DN is not a valid X.500 + * distinguished name or if secrets is empty. + * @return The credentials. + */ + public static X509CertificateCredential fromSubjectDn( + final String subjectDN, + final String issuerDN, + final String generatedAuthId, + final List secrets) { + + if (Strings.isNullOrEmpty(subjectDN)) { + throw new IllegalArgumentException("subject DN must not be null or empty"); + } + + final String formattedSubjectDN = new X500Principal(subjectDN).getName(X500Principal.RFC2253); + return Optional.ofNullable(issuerDN) + .map(X500Principal::new) + .map(x500Principal -> x500Principal.getName(X500Principal.RFC2253)) + .map(formattedIssuerDN -> new X509CertificateCredential(formattedSubjectDN, formattedIssuerDN, generatedAuthId, secrets)) + .orElse(new X509CertificateCredential(formattedSubjectDN, null, generatedAuthId, secrets)); } /** * Creates a new credentials object. * * @param certificate The X.509 certificate. + * @param generatedAuthId the authentication identifier generated by applying the auth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. * @return The credentials. * @throws NullPointerException if certificate is {@code null}. */ - public static X509CertificateCredential fromCertificate(final X509Certificate certificate) { - + public static X509CertificateCredential fromCertificate(final X509Certificate certificate, + final String generatedAuthId) { Objects.requireNonNull(certificate); + final var secret = new X509CertificateSecret(); secret.setNotBefore(certificate.getNotBefore().toInstant()); secret.setNotAfter(certificate.getNotAfter().toInstant()); + return new X509CertificateCredential( certificate.getSubjectX500Principal().getName(X500Principal.RFC2253), + certificate.getIssuerX500Principal().getName(X500Principal.RFC2253), + generatedAuthId, List.of(secret)); } @@ -145,20 +219,6 @@ private static X509Certificate deserialize(final byte[] base64EncodedX509Certifi } } - /** - * {@inheritDoc} - */ - @Override - protected Predicate getAuthIdValidator() { - return authId -> { - if (Strings.isNullOrEmpty(authId)) { - return false; - } - final X500Principal distinguishedName = new X500Principal(authId); - return distinguishedName.getName(X500Principal.RFC2253).equals(authId); - }; - } - /** * {@inheritDoc} */ @@ -198,4 +258,45 @@ public final X509CertificateCredential setSecrets(final Listauth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. + * + * @return The generated authentication identifier or {@code null}. + */ + protected String getGeneratedAuthId() { + return generatedAuthId; + } + + private void copyCommonAttributesTo(final CommonCredential credential) { + credential.setComment(getComment()); + credential.setEnabled(isEnabled()); + credential.setExtensions(getExtensions()); + } } diff --git a/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredentialWithGeneratedAuthId.java b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredentialWithGeneratedAuthId.java new file mode 100644 index 0000000000..8090267e0e --- /dev/null +++ b/services/device-registry-base/src/main/java/org/eclipse/hono/service/management/credentials/X509CertificateCredentialWithGeneratedAuthId.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.hono.service.management.credentials; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.hono.service.management.tenant.Tenant; +import org.eclipse.hono.util.IdentityTemplate; +import org.eclipse.hono.util.RegistryManagementConstants; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * An extended {@link X509CertificateCredential} to handle the generated authentication identifier. + */ +public class X509CertificateCredentialWithGeneratedAuthId extends X509CertificateCredential { + + /** + * Creates a new credentials object from the given authentication identifier, generated authentication + * identifier and secrets. + * + * @param authId The authentication identifier. + * @param generatedAuthId the authentication identifier generated by applying the auth-id-template + * from the tenant's trust anchor to the client certificate's subject DN. + * @param secrets The credential's secret(s). + * @throws NullPointerException if authentication identifier or secrets is {@code null}. + */ + private X509CertificateCredentialWithGeneratedAuthId(final String authId, final String generatedAuthId, + final List secrets) { + super(authId, generatedAuthId, secrets); + } + + /** + * Gets the authentication identifier generated by applying the auth-id-template from the tenant's + * trust anchor to the client certificate's subject DN. + * + * @return The generated authentication identifier or {@code null}. + */ + @JsonGetter(value = RegistryManagementConstants.FIELD_GENERATED_AUTH_ID) + @Override + public final String getGeneratedAuthId() { + return super.getGeneratedAuthId(); + } + + /** + * Applies the auth-id-template from the tenant's trust anchor to the client certificate's subject DN. + *

+ * It is only applicable, if a template is configured. + * + * @param credential The x509 certificate credential. + * @param tenant The tenant information. + * @return the credential with generated authentication identifier. + * @throws NullPointerException if the tenant is {@code null}. + */ + @JsonIgnore + public static X509CertificateCredentialWithGeneratedAuthId applyAuthIdTemplate( + final X509CertificateCredential credential, final Tenant tenant) { + Objects.requireNonNull(credential, "credential must not be null"); + Objects.requireNonNull(tenant, "tenant information must not be null"); + + final String generatedAuthId = Optional.ofNullable(credential.getIssuerDN()) + .flatMap(tenant::getAuthIdTemplate) + .map(IdentityTemplate::new) + .map(t -> t.apply(credential.getAuthId())) + .orElse(null); + final X509CertificateCredentialWithGeneratedAuthId credWithId = new X509CertificateCredentialWithGeneratedAuthId( + credential.getAuthId(), generatedAuthId, credential.getSecrets()); + + credWithId.setComment(credential.getComment()); + credWithId.setEnabled(credential.isEnabled()); + credWithId.setExtensions(credential.getExtensions()); + + return credWithId; + } +} diff --git a/services/device-registry-base/src/test/java/org/eclipse/hono/service/credentials/CredentialsServiceTestBase.java b/services/device-registry-base/src/test/java/org/eclipse/hono/service/credentials/CredentialsServiceTestBase.java index c4923ed050..3ef7507ca1 100644 --- a/services/device-registry-base/src/test/java/org/eclipse/hono/service/credentials/CredentialsServiceTestBase.java +++ b/services/device-registry-base/src/test/java/org/eclipse/hono/service/credentials/CredentialsServiceTestBase.java @@ -18,11 +18,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -38,6 +41,7 @@ import org.eclipse.hono.client.ClientErrorException; import org.eclipse.hono.client.ServiceInvocationException; +import org.eclipse.hono.deviceregistry.service.tenant.TenantInformationService; import org.eclipse.hono.deviceregistry.util.Assertions; import org.eclipse.hono.service.management.OperationResult; import org.eclipse.hono.service.management.credentials.CommonCredential; @@ -51,6 +55,8 @@ import org.eclipse.hono.service.management.credentials.X509CertificateSecret; import org.eclipse.hono.service.management.device.Device; import org.eclipse.hono.service.management.device.DeviceManagementService; +import org.eclipse.hono.service.management.tenant.Tenant; +import org.eclipse.hono.service.management.tenant.TrustedCertificateAuthority; import org.eclipse.hono.test.VertxTools; import org.eclipse.hono.util.CacheDirective; import org.eclipse.hono.util.CredentialsConstants; @@ -62,6 +68,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.opentracing.Span; import io.opentracing.noop.NoopSpan; import io.vertx.core.Future; import io.vertx.core.Promise; @@ -107,6 +114,16 @@ public interface CredentialsServiceTestBase { */ DeviceManagementService getDeviceManagementService(); + /** + * Gets the tenant information service. + *

+ * Return this tenant information service which is needed in order to work in coordination with the credentials + * service. + * + * @return The tenant information service. + */ + TenantInformationService getTenantInformationService(); + /** * Gets the cache directive that is supposed to be used for a given type of credentials. *

@@ -476,7 +493,7 @@ default void testUpdateCredentialsSucceeds(final Vertx vertx, final VertxTestCon tenantId, deviceId, List.of(pwdCredentials, otherPwdCredentials, pskCredentials, x509Credentials, - X509CertificateCredential.fromCertificate(clientCert.result())), + X509CertificateCredential.fromCertificate(clientCert.result(), null)), Optional.empty(), NoopSpan.INSTANCE); }) @@ -1130,6 +1147,71 @@ default void testUpdateCredentialsSupportsRemovingExistingCredentialsAndSecrets( })); } + /** + * Verifies that {@link CredentialsManagementService#updateCredentials(String, String, List, Optional, Span)} + * applies the authId template to generate the authId. + *

+ * Additionally, verifies that {@link CredentialsService#get(String, String, String, Span)} is able to find + * the credentials using the generated authId. + * + * @param ctx The vert.x test context. + */ + @Test + default void testUpdateCredentialsAppliesAuthIdTemplate(final VertxTestContext ctx) { + final String tenantId = UUID.randomUUID().toString(); + final String deviceId = UUID.randomUUID().toString(); + final String commonName = UUID.randomUUID().toString(); + final String issuerDN = "CN=testBase,OU=Hono,O=Eclipse"; + final String subjectDN = String.format("CN=%s,OU=Hono,O=Eclipse", commonName); + final String authIdTemplate = "auth-{{subject-cn}}-{{subject-ou}}-{{subject-o}}"; + final String expectedAuthIdByApplyingTemplate = String.format("auth-%s-Hono-Eclipse", commonName); + + final var credential = X509CertificateCredential.fromSubjectDn(subjectDN, issuerDN, null, + List.of(new X509CertificateSecret())); + final var trustedCa = new TrustedCertificateAuthority() + .setSubjectDn(issuerDN) + .setPublicKey("NOTAKEY".getBytes(StandardCharsets.UTF_8)) + .setAuthIdTemplate(authIdTemplate) + .setNotBefore(Instant.now().minus(1, ChronoUnit.DAYS)) + .setNotAfter(Instant.now().plus(2, ChronoUnit.DAYS)); + final var tenant = new Tenant().setTrustedCertificateAuthorities(List.of(trustedCa)); + when(getTenantInformationService().getTenant(any(), any())).thenReturn(Future.succeededFuture(tenant)); + + getDeviceManagementService() + .createDevice(tenantId, Optional.of(deviceId), new Device(), NoopSpan.INSTANCE) + .compose(response -> { + ctx.verify(() -> { + assertThat(response.getStatus()).isEqualTo(HttpURLConnection.HTTP_CREATED); + }); + return getCredentialsManagementService().updateCredentials( + tenantId, + deviceId, + List.of(credential), + Optional.empty(), + NoopSpan.INSTANCE); + }) + .compose(response -> { + ctx.verify(() -> { + assertThat(response.getStatus()).isEqualTo(HttpURLConnection.HTTP_NO_CONTENT); + assertResourceVersion(response); + }); + // WHEN retrieving the credentials via the Credentials API + return getCredentialsService().get(tenantId, RegistryManagementConstants.SECRETS_TYPE_X509_CERT, + expectedAuthIdByApplyingTemplate); + }) + .onComplete(ctx.succeeding(response -> { + ctx.verify(() -> { + assertThat(response.isOk()).isTrue(); + final JsonObject credentialJson = response.getPayload(); + // VERIFY that the auth-id is set to the generated auth-id + assertWithMessage("credentials auth-id") + .that(credentialJson.getString(RegistryManagementConstants.FIELD_AUTH_ID)) + .isEqualTo(expectedAuthIdByApplyingTemplate); + }); + ctx.completeNow(); + })); + } + /** * Verifies that a credential's existing secrets can be updated using their IDs. * diff --git a/services/device-registry-base/src/test/java/org/eclipse/hono/service/management/credentials/CredentialsTest.java b/services/device-registry-base/src/test/java/org/eclipse/hono/service/management/credentials/CredentialsTest.java index 7ea8fb5743..c77d0827e6 100644 --- a/services/device-registry-base/src/test/java/org/eclipse/hono/service/management/credentials/CredentialsTest.java +++ b/services/device-registry-base/src/test/java/org/eclipse/hono/service/management/credentials/CredentialsTest.java @@ -31,15 +31,19 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.UUID; import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Stream; import javax.security.auth.x500.X500Principal; +import org.eclipse.hono.service.management.tenant.Tenant; +import org.eclipse.hono.service.management.tenant.TrustedCertificateAuthority; import org.eclipse.hono.util.RegistryManagementConstants; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -242,18 +246,21 @@ public void testDecodePskCredential() { } /** - * Test encoding an X.509 secret. + * Test encoding an X.509 credential. */ @Test public void testEncodeX509Credential() { final X509CertificateSecret x509Secret = new X509CertificateSecret(); addCommonProperties(x509Secret); - var credential = X509CertificateCredential.fromSubjectDn("CN=foo, O=bar", List.of(x509Secret)); + var credential = X509CertificateCredential.fromSubjectDn("CN=foo, O=bar", "CN=test, O=bar", "foo-bar", + List.of(x509Secret)); JsonObject json = JsonObject.mapFrom(credential); assertEquals("x509-cert", json.getString(RegistryManagementConstants.FIELD_TYPE)); assertEquals("CN=foo,O=bar", json.getString(RegistryManagementConstants.FIELD_AUTH_ID)); + assertEquals(false, json.containsKey(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID)); + assertEquals(false, json.containsKey(RegistryManagementConstants.FIELD_ISSUER_DN)); JsonObject secret = json.getJsonArray(RegistryManagementConstants.FIELD_SECRETS).getJsonObject(0); assertCommonSecretProperties(secret); @@ -295,6 +302,106 @@ public void testDecodeX509CredentialFromSubjectDn() { assertCommonSecretProperties(secret); } + /** + * Verifies that a JSON object containing an auth-id, generated-auth-id and a secret can be decoded into an X.509 + * credential. + */ + @Test + void testDecodeX509CredentialWithGeneratedAuthId() { + final JsonObject jsonCredential = new JsonObject() + .put(RegistryManagementConstants.FIELD_TYPE, RegistryManagementConstants.SECRETS_TYPE_X509_CERT) + .put(RegistryManagementConstants.FIELD_AUTH_ID, "CN=Acme") + .put(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID, "Acme") + .put(RegistryManagementConstants.FIELD_COMMENT, "comment") + .put(RegistryManagementConstants.FIELD_ENABLED, true) + .put(RegistryManagementConstants.FIELD_SECRETS, new JsonArray() + .add(new JsonObject() + .put(RegistryManagementConstants.FIELD_ENABLED, true) + .put(RegistryManagementConstants.FIELD_SECRETS_NOT_BEFORE, NOT_BEFORE_STRING) + .put(RegistryManagementConstants.FIELD_SECRETS_NOT_AFTER, NOT_AFTER_STRING) + .put(RegistryManagementConstants.FIELD_SECRETS_COMMENT, SECRET_COMMENT))); + + final X509CertificateCredential credential = jsonCredential.mapTo(X509CertificateCredential.class); + + assertEquals("CN=Acme", credential.getAuthId()); + assertEquals("Acme", credential.getGeneratedAuthId()); + assertEquals("comment", credential.getComment()); + assertTrue(credential.isEnabled()); + assertEquals(1, credential.getSecrets().size()); + + final X509CertificateSecret secret = credential.getSecrets().get(0); + assertCommonSecretProperties(secret); + } + + /** + * Verifies an X.509 credential obtained by overriding the authId with the generated authId. + */ + @Test + void testX509CredentialObtainedByOverridingAuthId() { + final String subjectDn = "CN=foo, O=bar"; + final String generatedAuthId = "foo-bar"; + final X509CertificateSecret x509Secret = new X509CertificateSecret(); + addCommonProperties(x509Secret); + final var credential = X509CertificateCredential.fromSubjectDn(subjectDn, null, generatedAuthId, + List.of(x509Secret)); + final X509CertificateCredential credentialWithOverriddenAuthId = credential.overrideAuthIdWithGeneratedAuthId(); + + assertEquals(generatedAuthId, credentialWithOverriddenAuthId.getAuthId()); + assertTrue(credentialWithOverriddenAuthId.isEnabled()); + assertEquals(1, credentialWithOverriddenAuthId.getSecrets().size()); + + final X509CertificateSecret secret = credentialWithOverriddenAuthId.getSecrets().get(0); + assertCommonSecretProperties(secret); + } + + /** + * Verifies an X.509 credential obtained by applying the given auth-id-template + * to the client certificate's subject DN. + */ + @Test + void testX509CredentialWithGeneratedAuthIdObtainedByApplyingAuthIdTemplate() { + final String commonName = UUID.randomUUID().toString(); + final String issuerDn = "CN=testBase,OU=Hono,O=Eclipse"; + final String subjectDN = String.format("CN=%s,OU=Hono,O=Eclipse", commonName); + final String authIdTemplate = "auth-{{subject-cn}}-{{subject-ou}}-{{subject-o}}"; + final String generatedAuthId = String.format("auth-%s-Hono-Eclipse", commonName); + + final var trustedCa = new TrustedCertificateAuthority() + .setSubjectDn(issuerDn) + .setPublicKey("NOTAKEY".getBytes(StandardCharsets.UTF_8)) + .setAuthIdTemplate(authIdTemplate) + .setNotBefore(Instant.now().minus(1, ChronoUnit.DAYS)) + .setNotAfter(Instant.now().plus(2, ChronoUnit.DAYS)); + final var tenant = new Tenant().setTrustedCertificateAuthorities(List.of(trustedCa)); + final X509CertificateSecret x509Secret = new X509CertificateSecret(); + addCommonProperties(x509Secret); + final var credential = X509CertificateCredential.fromSubjectDn(subjectDN, issuerDn, null, List.of(x509Secret)); + credential.setEnabled(true) + .setComment("comment"); + + final X509CertificateCredentialWithGeneratedAuthId credentialWithGeneratedAuthId = X509CertificateCredentialWithGeneratedAuthId + .applyAuthIdTemplate(credential, tenant); + + assertEquals(subjectDN, credentialWithGeneratedAuthId.getAuthId()); + assertEquals(generatedAuthId, credentialWithGeneratedAuthId.getGeneratedAuthId()); + assertEquals("comment", credentialWithGeneratedAuthId.getComment()); + assertTrue(credentialWithGeneratedAuthId.isEnabled()); + assertEquals(1, credentialWithGeneratedAuthId.getSecrets().size()); + + final X509CertificateSecret secret = credentialWithGeneratedAuthId.getSecrets().get(0); + assertCommonSecretProperties(secret); + + final JsonObject json = JsonObject.mapFrom(credentialWithGeneratedAuthId); + + assertEquals("x509-cert", json.getString(RegistryManagementConstants.FIELD_TYPE)); + assertEquals(subjectDN, json.getString(RegistryManagementConstants.FIELD_AUTH_ID)); + assertEquals(generatedAuthId, json.getString(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID)); + assertEquals("comment", json.getString(RegistryManagementConstants.FIELD_COMMENT)); + + final JsonObject secretJson = json.getJsonArray(RegistryManagementConstants.FIELD_SECRETS).getJsonObject(0); + assertCommonSecretProperties(secretJson); + } + /** * Verifies that a JSON object containing a client certificate can be decoded into an X.509 credential. * diff --git a/services/device-registry-file/src/test/java/org/eclipse/hono/deviceregistry/file/FileBasedCredentialsServiceTest.java b/services/device-registry-file/src/test/java/org/eclipse/hono/deviceregistry/file/FileBasedCredentialsServiceTest.java index a6d118cf63..319818fc2d 100644 --- a/services/device-registry-file/src/test/java/org/eclipse/hono/deviceregistry/file/FileBasedCredentialsServiceTest.java +++ b/services/device-registry-file/src/test/java/org/eclipse/hono/deviceregistry/file/FileBasedCredentialsServiceTest.java @@ -35,16 +35,19 @@ import org.eclipse.hono.auth.HonoPasswordEncoder; import org.eclipse.hono.auth.SpringBasedHonoPasswordEncoder; import org.eclipse.hono.deviceregistry.DeviceRegistryTestUtils; -import org.eclipse.hono.deviceregistry.service.tenant.NoopTenantInformationService; +import org.eclipse.hono.deviceregistry.service.tenant.TenantInformationService; +import org.eclipse.hono.deviceregistry.service.tenant.TenantKey; import org.eclipse.hono.deviceregistry.util.Assertions; import org.eclipse.hono.service.credentials.CredentialsService; import org.eclipse.hono.service.credentials.CredentialsServiceTestBase; +import org.eclipse.hono.service.management.OperationResult; import org.eclipse.hono.service.management.credentials.CommonCredential; import org.eclipse.hono.service.management.credentials.Credentials; import org.eclipse.hono.service.management.credentials.CredentialsManagementService; import org.eclipse.hono.service.management.credentials.PasswordCredential; import org.eclipse.hono.service.management.credentials.PskCredential; import org.eclipse.hono.service.management.device.DeviceManagementService; +import org.eclipse.hono.service.management.tenant.Tenant; import org.eclipse.hono.util.CacheDirective; import org.eclipse.hono.util.Constants; import org.eclipse.hono.util.CredentialsConstants; @@ -94,6 +97,7 @@ public class FileBasedCredentialsServiceTest implements CredentialsServiceTestBa private FileBasedRegistrationService registrationService; private FileBasedCredentialsService credentialsService; + private TenantInformationService tenantInformationService; private FileBasedDeviceBackend svc; @@ -126,9 +130,20 @@ public void setUp() { this.registrationService = new FileBasedRegistrationService(vertx); this.registrationService.setConfig(registrationConfig); + this.tenantInformationService = mock(TenantInformationService.class); + when(tenantInformationService.getTenant(anyString(), any())).thenReturn(Future.succeededFuture(new Tenant())); + when(tenantInformationService.tenantExists(anyString(), any())).thenAnswer(invocation -> { + return Future.succeededFuture(OperationResult.ok( + HttpURLConnection.HTTP_OK, + TenantKey.from(invocation.getArgument(0)), + Optional.empty(), + Optional.empty())); + }); + this.credentialsService = new FileBasedCredentialsService(vertx, credentialsConfig, PASSWORD_ENCODER); + this.credentialsService.setTenantInformationService(tenantInformationService); - this.svc = new FileBasedDeviceBackend(this.registrationService, this.credentialsService, new NoopTenantInformationService()); + this.svc = new FileBasedDeviceBackend(this.registrationService, this.credentialsService, tenantInformationService); } @Override @@ -146,6 +161,11 @@ public DeviceManagementService getDeviceManagementService() { return this.svc; } + @Override + public TenantInformationService getTenantInformationService() { + return this.tenantInformationService; + } + /** * {@inheritDoc} */ @@ -607,4 +627,9 @@ private void testGetCredentialsWithClientContext( })); } + @Override + public void testUpdateCredentialsAppliesAuthIdTemplate(final VertxTestContext ctx) { + // The deprecated file based registry is not extended to support auth-id-template, + // hence this test has been disabled. + } } diff --git a/services/device-registry-jdbc/src/main/java/org/eclipse/hono/deviceregistry/jdbc/impl/CredentialsServiceImpl.java b/services/device-registry-jdbc/src/main/java/org/eclipse/hono/deviceregistry/jdbc/impl/CredentialsServiceImpl.java index cf8fcc734f..d21ed86981 100644 --- a/services/device-registry-jdbc/src/main/java/org/eclipse/hono/deviceregistry/jdbc/impl/CredentialsServiceImpl.java +++ b/services/device-registry-jdbc/src/main/java/org/eclipse/hono/deviceregistry/jdbc/impl/CredentialsServiceImpl.java @@ -29,9 +29,11 @@ import org.eclipse.hono.deviceregistry.service.tenant.TenantKey; import org.eclipse.hono.deviceregistry.util.DeviceRegistryUtils; import org.eclipse.hono.service.base.jdbc.store.device.TableAdapterStore; +import org.eclipse.hono.service.management.credentials.X509CertificateCredential; import org.eclipse.hono.util.Constants; import org.eclipse.hono.util.CredentialsConstants; import org.eclipse.hono.util.CredentialsResult; +import org.eclipse.hono.util.Strings; import io.opentracing.Span; import io.vertx.core.Future; @@ -76,6 +78,12 @@ protected Future> processGet( final var secrets = result.getCredentials() .stream() + .map(credential -> { + if (credential instanceof X509CertificateCredential) { + return ((X509CertificateCredential) credential).overrideAuthIdWithGeneratedAuthId(); + } + return credential; + }) .map(JsonObject::mapFrom) .filter(filter(key.getType(), key.getAuthId())) .filter(credential -> DeviceRegistryUtils.matchesWithClientContext(credential, clientContext)) @@ -152,11 +160,12 @@ private static Predicate filter(final String type, final String auth return false; } - if (!authId.equals(c.getString(CredentialsConstants.FIELD_AUTH_ID))) { - return false; + final String generatedAuthId = c.getString(CredentialsConstants.FIELD_GENERATED_AUTH_ID); + if (Strings.isNullOrEmpty(generatedAuthId)) { + return authId.equals(c.getString(CredentialsConstants.FIELD_AUTH_ID)); + } else { + return authId.equals(generatedAuthId); } - - return true; }; } diff --git a/services/device-registry-jdbc/src/test/java/org/eclipse/hono/deviceregistry/jdbc/impl/AbstractJdbcRegistryTest.java b/services/device-registry-jdbc/src/test/java/org/eclipse/hono/deviceregistry/jdbc/impl/AbstractJdbcRegistryTest.java index 646108acac..d29152759f 100644 --- a/services/device-registry-jdbc/src/test/java/org/eclipse/hono/deviceregistry/jdbc/impl/AbstractJdbcRegistryTest.java +++ b/services/device-registry-jdbc/src/test/java/org/eclipse/hono/deviceregistry/jdbc/impl/AbstractJdbcRegistryTest.java @@ -256,4 +256,13 @@ public CredentialsService getCredentialsService() { public CredentialsManagementService getCredentialsManagementService() { return this.credentialsManagement; } + + /** + * Gets the tenant information service. + * + * @return The tenant information service. + */ + public TenantInformationService getTenantInformationService() { + return this.tenantInformationService; + } } diff --git a/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/model/MongoDbBasedCredentialsDao.java b/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/model/MongoDbBasedCredentialsDao.java index e1ff805aeb..8ec90864f6 100644 --- a/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/model/MongoDbBasedCredentialsDao.java +++ b/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/model/MongoDbBasedCredentialsDao.java @@ -53,7 +53,7 @@ public final class MongoDbBasedCredentialsDao extends MongoDbBasedDao implements /** * The name of the index on the Credentials type and authentication identifier. */ - public static final String IDX_CREDENTIALS_TYPE_AND_AUTH_ID = "credentials_type_and_auth_id"; + public static final String IDX_CREDENTIALS_TYPE_AND_AUTH_ID_AND_GENERATED_AUTH_ID = "credentials_type_and_auth_id_and_generated_id"; /** * The projection document used for querying credentials by type and authentication identifier. */ @@ -66,6 +66,9 @@ public final class MongoDbBasedCredentialsDao extends MongoDbBasedDao implements private static final String KEY_AUTH_ID = String.format( "%s.%s", CredentialsDto.FIELD_CREDENTIALS, RegistryManagementConstants.FIELD_AUTH_ID); + private static final String KEY_GENERATED_AUTH_ID = String.format( + "%s.%s", + CredentialsDto.FIELD_CREDENTIALS, RegistryManagementConstants.FIELD_GENERATED_AUTH_ID); private static final String KEY_CREDENTIALS_TYPE = String.format( "%s.%s", CredentialsDto.FIELD_CREDENTIALS, RegistryManagementConstants.FIELD_TYPE); @@ -122,9 +125,10 @@ public Future createIndices() { .put(KEY_CREDENTIALS_TYPE, new JsonObject().put("$exists", true))))) .compose(ok -> createIndex( new JsonObject() - .put(KEY_AUTH_ID, 1) - .put(KEY_CREDENTIALS_TYPE, 1), - new IndexOptions().name(IDX_CREDENTIALS_TYPE_AND_AUTH_ID))) + .put(KEY_CREDENTIALS_TYPE, 1) + .put(KEY_GENERATED_AUTH_ID, 1) + .put(KEY_AUTH_ID, 1), + new IndexOptions().name(IDX_CREDENTIALS_TYPE_AND_AUTH_ID_AND_GENERATED_AUTH_ID))) .onSuccess(ok -> indicesCreated.set(true)) .onComplete(r -> { creatingIndices.set(false); @@ -284,8 +288,7 @@ public Future getByAuthIdAndType( final JsonObject filter = MongoDbDocumentBuilder.builder() .withTenantId(tenantId) - .withAuthId(authId) - .withType(type) + .withTypeAndAuthId(type, authId) .document(); if (LOG.isTraceEnabled()) { diff --git a/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialsService.java b/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialsService.java index d5ea99b6dd..5577df0579 100644 --- a/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialsService.java +++ b/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialsService.java @@ -24,6 +24,8 @@ import org.eclipse.hono.deviceregistry.service.credentials.CredentialKey; import org.eclipse.hono.deviceregistry.service.tenant.TenantKey; import org.eclipse.hono.deviceregistry.util.DeviceRegistryUtils; +import org.eclipse.hono.service.management.credentials.CommonCredential; +import org.eclipse.hono.service.management.credentials.X509CertificateCredential; import org.eclipse.hono.util.CacheDirective; import org.eclipse.hono.util.CredentialsConstants; import org.eclipse.hono.util.CredentialsResult; @@ -99,14 +101,18 @@ protected Future> processGet( return dao.getByAuthIdAndType(tenant.getTenantId(), key.getAuthId(), key.getType(), span.context()) .map(dto -> { LOG.trace("found credentials matching criteria"); - final var json = JsonObject.mapFrom(dto.getCredentials().get(0)); + CommonCredential credential = dto.getCredentials().get(0); + if (credential instanceof X509CertificateCredential) { + credential = ((X509CertificateCredential) credential).overrideAuthIdWithGeneratedAuthId(); + } + final var json = JsonObject.mapFrom(credential); json.put(CredentialsConstants.FIELD_PAYLOAD_DEVICE_ID, dto.getDeviceId()); return Optional.of(json) .filter(MongoDbBasedCredentialsService::isCredentialEnabled) - .filter(credential -> DeviceRegistryUtils.matchesWithClientContext(credential, clientContext)) - .map(credential -> CredentialsResult.from( + .filter(cred -> DeviceRegistryUtils.matchesWithClientContext(cred, clientContext)) + .map(cred -> CredentialsResult.from( HttpURLConnection.HTTP_OK, - credential, + cred, getCacheDirective(key.getType()))) .orElseThrow(() -> new ClientErrorException( tenant.getTenantId(), diff --git a/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/utils/MongoDbDocumentBuilder.java b/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/utils/MongoDbDocumentBuilder.java index cc94a47c18..30a95d2dee 100644 --- a/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/utils/MongoDbDocumentBuilder.java +++ b/services/device-registry-mongodb/src/main/java/org/eclipse/hono/deviceregistry/mongodb/utils/MongoDbDocumentBuilder.java @@ -28,6 +28,7 @@ import org.eclipse.hono.util.AuthenticationConstants; import org.eclipse.hono.util.RegistryManagementConstants; +import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.json.pointer.JsonPointer; @@ -42,6 +43,7 @@ public final class MongoDbDocumentBuilder { RegistryManagementConstants.FIELD_PAYLOAD_TRUSTED_CA, AuthenticationConstants.FIELD_SUBJECT_DN); private static final String MONGODB_OPERATOR_ELEM_MATCH = "$elemMatch"; + private static final String MONGODB_OPERATOR_OR = "$or"; private final JsonObject document; @@ -113,13 +115,59 @@ public JsonObject document() { return document; } + /** + * Sets the json object with the given credentials type and auth id. + *

+ * If the type equals {@value RegistryManagementConstants#SECRETS_TYPE_X509_CERT}, + * the MongoDB query is framed to match the given auth id + *

    + *
  • with the field generated-auth-id.
  • + *
  • if no match found and the generated-auth-id is {@code null}, the field auth-id is used. + *
  • + *
+ * Example query with type as {@value RegistryManagementConstants#SECRETS_TYPE_X509_CERT} + * and auth id as "Device1-Hono-Eclipse": + *
+     * {
+     *     "credentials": {
+     *         "$elemMatch": {
+     *             "type": {@value RegistryManagementConstants#SECRETS_TYPE_X509_CERT},
+     *             "$or": [
+     *                 {
+     *                     "generated-auth-id": "Device1-Hono-Eclipse"
+     *                 },
+     *                 {
+     *                     "generated-auth-id": null,
+     *                     "auth-id": "Device1-Hono-Eclipse"
+     *                 }
+     *             ]
+     *         }
+     *     }
+     * }
+     * 
+ * Else the given auth-id is matched with the field auth-id. + * + * @param type The credentials type. + * @param authId The authentication identifier + * @return a reference to this for fluent use. + */ + public MongoDbDocumentBuilder withTypeAndAuthId(final String type, final String authId) { + withType(type); + if (RegistryManagementConstants.SECRETS_TYPE_X509_CERT.equals(type)) { + withGeneratedAuthId(authId); + } else { + withAuthId(authId); + } + return this; + } + /** * Sets the json object with the given credentials type. * * @param type The credentials type. * @return a reference to this for fluent use. */ - public MongoDbDocumentBuilder withType(final String type) { + private MongoDbDocumentBuilder withType(final String type) { return withCredentialsPredicate(RegistryManagementConstants.FIELD_TYPE, type); } @@ -129,10 +177,57 @@ public MongoDbDocumentBuilder withType(final String type) { * @param authId The auth id. * @return a reference to this for fluent use. */ - public MongoDbDocumentBuilder withAuthId(final String authId) { + private MongoDbDocumentBuilder withAuthId(final String authId) { return withCredentialsPredicate(RegistryManagementConstants.FIELD_AUTH_ID, authId); } + /** + * Sets the json object with the given auth id. + *

+ * A MongoDB query is framed to match the given auth id to + *

    + *
  • the field generated-auth-id.
  • + *
  • If no match found and generated-auth-id is {@code null}, match with the field auth-id. + *
  • + *
+ *

+ * Example query with auth id as "Device1-Hono-Eclipse": + *

+     * {
+     *     "credentials": {
+     *         "$elemMatch": {
+     *             "$or": [
+     *                 {
+     *                     "generated-auth-id": "Device1-Hono-Eclipse"
+     *                 },
+     *                 {
+     *                     "generated-auth-id": null,
+     *                     "auth-id": "Device1-Hono-Eclipse"
+     *                 }
+     *             ]
+     *         }
+     *     }
+     * }
+     * 
+ * + * @param authId The auth id. + * @return a reference to this for fluent use. + */ + private MongoDbDocumentBuilder withGeneratedAuthId(final String authId) { + final var credentialsArraySpec = document.getJsonObject(CredentialsDto.FIELD_CREDENTIALS, new JsonObject()); + final var elementMatchSpec = credentialsArraySpec.getJsonObject(MONGODB_OPERATOR_ELEM_MATCH, new JsonObject()); + + elementMatchSpec.put(MONGODB_OPERATOR_OR, new JsonArray(List.of( + new JsonObject().put(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID, authId), + new JsonObject() + .put(RegistryManagementConstants.FIELD_GENERATED_AUTH_ID, null) + .put(RegistryManagementConstants.FIELD_AUTH_ID, authId)))); + credentialsArraySpec.put(MONGODB_OPERATOR_ELEM_MATCH, elementMatchSpec); + document.put(CredentialsDto.FIELD_CREDENTIALS, credentialsArraySpec); + + return this; + } + private MongoDbDocumentBuilder withCredentialsPredicate(final String field, final String value) { final var credentialsArraySpec = document.getJsonObject(CredentialsDto.FIELD_CREDENTIALS, new JsonObject()); final var elementMatchSpec = credentialsArraySpec.getJsonObject(MONGODB_OPERATOR_ELEM_MATCH, new JsonObject()); diff --git a/services/device-registry-mongodb/src/test/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialServiceTest.java b/services/device-registry-mongodb/src/test/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialServiceTest.java index 7edad15c66..da15d1cce1 100644 --- a/services/device-registry-mongodb/src/test/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialServiceTest.java +++ b/services/device-registry-mongodb/src/test/java/org/eclipse/hono/deviceregistry/mongodb/service/MongoDbBasedCredentialServiceTest.java @@ -193,6 +193,11 @@ public DeviceManagementService getDeviceManagementService() { return this.deviceManagementService; } + @Override + public TenantInformationService getTenantInformationService() { + return this.tenantInformationService; + } + /** * Verifies that a request to update credentials of a device fails with a 403 status code * if the number of credentials exceeds the tenant's configured limit. @@ -264,46 +269,68 @@ public void testCredentialsDaoUsesIndex(final VertxTestContext ctx) { Credentials.createPasswordCredential("device1a", "secret"), Credentials.createPSKCredential("device1b", "shared-secret")), UUID.randomUUID().toString()); + final var dto5 = CredentialsDto.forCreation( + tenantId, + UUID.randomUUID().toString(), + List.of(Credentials.createX509CertificateCredential("CN=testDevice1,OU=Hono,O=Eclipse"), + Credentials.createPSKCredential("device5b", "shared-secret")), + UUID.randomUUID().toString()); + final var dto6 = CredentialsDto.forCreation( + tenantId, + UUID.randomUUID().toString(), + List.of(Credentials.createX509CertificateCredential("CN=Device1,OU=Hono,O=Eclipse"), + Credentials.createPSKCredential("device6b", "shared-secret")), + UUID.randomUUID().toString()); credentialsDao.create(dto1, NoopSpan.INSTANCE.context()) - .compose(ok -> credentialsDao.create(dto2, NoopSpan.INSTANCE.context())) - .compose(ok -> credentialsDao.create(dto3, NoopSpan.INSTANCE.context())) - .compose(ok -> credentialsDao.create(dto4, NoopSpan.INSTANCE.context())) - .compose(ok -> { - final Promise resultHandler = Promise.promise(); - final var filter = MongoDbDocumentBuilder.builder() - .withTenantId(tenantId) - .withAuthId("device1a") - .withType(CredentialsConstants.SECRETS_TYPE_HASHED_PASSWORD) - .document(); - final var commandRight = new JsonObject() - .put("find", "credentials") - .put("batchSize", 1) - .put("singleBatch", true) - .put("filter", filter) - .put("projection", MongoDbBasedCredentialsDao.PROJECTION_CREDS_BY_TYPE_AND_AUTH_ID); - final var explain = new JsonObject() - .put("explain", commandRight) - .put("verbosity", "executionStats"); - mongoClient.runCommand("explain", explain, resultHandler); - return resultHandler.future(); - }) - .onComplete(ctx.succeeding(result -> { - if (LOG.isTraceEnabled()) { - LOG.trace("result:{}{}", System.lineSeparator(), result.encodePrettily()); - } - ctx.verify(() -> { - final var indexScan = (JsonObject) JsonPointer.from("/queryPlanner/winningPlan/inputStage/inputStage") - .queryJson(result); - assertThat(indexScan.getString("indexName")) - .isEqualTo(MongoDbBasedCredentialsDao.IDX_CREDENTIALS_TYPE_AND_AUTH_ID); - final var executionStats = result.getJsonObject("executionStats", new JsonObject()); - // there are two credentials with auth-id "device1a" and type "hashed-password" - assertThat(executionStats.getInteger("totalKeysExamined")).isEqualTo(2); - assertThat(executionStats.getInteger("totalDocsExamined")).isEqualTo(2); - }); - ctx.completeNow(); - })); + .compose(ok -> credentialsDao.create(dto2, NoopSpan.INSTANCE.context())) + .compose(ok -> credentialsDao.create(dto3, NoopSpan.INSTANCE.context())) + .compose(ok -> credentialsDao.create(dto4, NoopSpan.INSTANCE.context())) + .compose(ok -> credentialsDao.create(dto5, NoopSpan.INSTANCE.context())) + .compose(ok -> credentialsDao.create(dto6, NoopSpan.INSTANCE.context())) + .compose(ok -> getExecutionStatistics(tenantId, CredentialsConstants.SECRETS_TYPE_HASHED_PASSWORD, + "device1a", mongoClient)) + .compose(result -> { + verifyExecutionStatistics(result, 4, 2, ctx); + return getExecutionStatistics(tenantId, CredentialsConstants.SECRETS_TYPE_X509_CERT, + "CN=testDevice1,OU=Hono,O=Eclipse", mongoClient); + }) + .onComplete(ctx.succeeding(result -> { + verifyExecutionStatistics(result, 2, 2, ctx); + ctx.completeNow(); + })); + } + + private Future getExecutionStatistics(final String tenantId, final String type, final String authId, + final MongoClient mongoClient) { + final Promise resultHandler = Promise.promise(); + final var filter = MongoDbDocumentBuilder.builder() + .withTenantId(tenantId) + .withTypeAndAuthId(type, authId) + .document(); + final var commandRight = new JsonObject() + .put("find", "credentials") + .put("batchSize", 1) + .put("singleBatch", true) + .put("filter", filter) + .put("projection", MongoDbBasedCredentialsDao.PROJECTION_CREDS_BY_TYPE_AND_AUTH_ID); + final var explain = new JsonObject() + .put("explain", commandRight) + .put("verbosity", "executionStats"); + mongoClient.runCommand("explain", explain, resultHandler); + return resultHandler.future(); + } + private void verifyExecutionStatistics(final JsonObject result, final int expectedTotalKeysExamined, + final int expectedTotalDocsExamined, final VertxTestContext ctx) { + ctx.verify(() -> { + final var indexScan = (JsonObject) JsonPointer.from("/queryPlanner/winningPlan/inputStage/inputStage") + .queryJson(result); + assertThat(indexScan.getString("indexName")) + .isEqualTo(MongoDbBasedCredentialsDao.IDX_CREDENTIALS_TYPE_AND_AUTH_ID_AND_GENERATED_AUTH_ID); + final var executionStats = result.getJsonObject("executionStats", new JsonObject()); + assertThat(executionStats.getInteger("totalKeysExamined")).isEqualTo(expectedTotalKeysExamined); + assertThat(executionStats.getInteger("totalDocsExamined")).isEqualTo(expectedTotalDocsExamined); + }); } } diff --git a/site/documentation/content/api/management/device-registry-v1.yaml b/site/documentation/content/api/management/device-registry-v1.yaml index 6dd698c6ce..0a70f81a08 100644 --- a/site/documentation/content/api/management/device-registry-v1.yaml +++ b/site/documentation/content/api/management/device-registry-v1.yaml @@ -1398,9 +1398,17 @@ components: The *subject DN* of the public key contained in the device's X.509 certificate. The value MUST be formatted according to the rules defined in [RFC 2253](https://tools.ietf.org/html/rfc2253#section-2). - If the property *auth-id-template* is specified in the tenant's trusted anchor, - the implementors of this API MUST generate *auth-id* by applying the template to - the given *subject DN*. Otherwise the *Subject DN* is stored as the *auth-id*. + "issuer-dn": + type: string + description: | + The *issuer DN* of the public key contained in the device's X.509 certificate. + The value MUST be formatted according to the rules defined in + [RFC 2253](https://tools.ietf.org/html/rfc2253#section-2). + This *issuer DN* is used to retrieve the *auth-id-template* from the tenant's + trust anchor. The template is used to generate an additional *auth-id* by applying + the template to the given *subject DN*. While retreiving a *x509-cert* credential + by *auth-id*, the device registry MUST look for a matching *generated-auth-id* + and if not available then *subject-DN* is matched. "secrets": type: array items: @@ -1417,13 +1425,15 @@ components: The Base64 encoded binary DER encoding of the device's X.509 client certificate. This property can be used as a convenient alternative to specifying the certificate's meta data in the *auth-id* and *secrets* explicitly. - When provided in a request body, implementors of this API MUST extract the subject DN - from the certificate's public key. The *subject DN* MUST be formatted according to the - rules defined in [RFC 2253](https://tools.ietf.org/html/rfc2253#section-2). - The extracted *subject DN* is used as the *auth-id* if there is no *auth-id-template* - specified in the tenant's trusted anchor. Otherwise, the *auth-id* MUST be generated - by applying the template to the extracted *subject DN*. The certificate's validity - period MUST be stored in a single secret. The certificate itself must then be discarded. + When provided in a request body, implementors of this API MUST extract the *subject DN* + from the certificate's public key and store its value properly formatted to the *auth-id* + property. The *issuer DN* is used to retrieve the *auth-id-template* from the tenant's + trust anchor. The template is used to generate an additional *auth-id* by applying + the template to the given *subject DN*. While retreiving a *x509-cert* credential + by *auth-id*, the device registry MUST look for a matching *generated-auth-id* + and if not available then *subject-DN* is matched. + The certificate's validity period MUST be stored in a single secret. + The certificate itself must then be discarded. CommonSecret: type: object