Refactor header authority controller authentication (#2954)

1. (breaking changes) hawkbit.server.ddi.security.rp.cnHeader and sslIssuerHashHeader are renamed to controllerIdHeader and authorityHeader correspondingly.
2. (breaking changes) their default values are changed: X-Ssl-Client-Cn -> X-Controller-Id and X-Ssl-Issuer-Hash-%d -> X-Authority
3. Now the authority header configuration is not a string forma but just a string. The implemenation checks for this header as comma or ; separated list or seeks for header iteration <authority_header>-%d (iteration starts from 0 or 1
4. Doc fixed
5. As there are breaking changes configuration changes may be needed: a) with changing the hawkbit.server.ddi.security.rp you could turn back the previous default headers (note X-Ssl-Issuer-Hash-%d shall now be X-Ssl-Issuer-Hash), or b) you may change the headers sent by the reverse proxy

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2026-03-12 10:36:37 +02:00
committed by GitHub
parent a1608cce19
commit 011d7f567e
8 changed files with 127 additions and 126 deletions

View File

@@ -29,14 +29,6 @@ public class DdiSecurityProperties {
private final Rp rp = new Rp();
private final Authentication authentication = new Authentication();
public Authentication getAuthentication() {
return authentication;
}
public Rp getRp() {
return rp;
}
/**
* Reverse proxy configuration. Defines the security properties for
* authenticating controllers behind a reverse proxy which terminates the
@@ -47,16 +39,19 @@ public class DdiSecurityProperties {
public static class Rp {
/**
* HTTP header field for common name of a DDI target client certificate.
* HTTP header field for controller ID (e.g. CN of the controller certificate) of a DDI target client certificate.
*/
private String cnHeader = "X-Ssl-Client-Cn";
private String controllerIdHeader = "X-Controller-Id";
/**
* HTTP header field for issuer hash of a DDI target client certificate.
* HTTP header field for authority(ies) (e.g. SHA-256 fingerprints of issuer certificates) of a DDI target client certificate.
*/
private String sslIssuerHashHeader = "X-Ssl-Issuer-Hash-%d";
private String authorityHeader = "X-Authority";
/**
* List of trusted (reverse proxy) IP addresses for performing DDI
* client certificate auth.
* Regular expression for authorities list separator
*/
private String authoritiesSeparatorRegex = "[;,]";
/**
* List of trusted (reverse proxy) IP addresses for performing DDI client certificate auth.
*/
private List<String> trustedIPs;
}
@@ -67,14 +62,14 @@ public class DdiSecurityProperties {
@Data
public static class Authentication {
private final Targettoken targettoken = new Targettoken();
private final Gatewaytoken gatewaytoken = new Gatewaytoken();
private final TargetToken targettoken = new TargetToken();
private final GatewayToken gatewaytoken = new GatewayToken();
/**
* Target token auth. Tokens are defined per target.
*/
@Data
public static class Targettoken {
public static class TargetToken {
/**
* Set to true to enable target token auth.
@@ -86,7 +81,7 @@ public class DdiSecurityProperties {
* Gateway token auth. Tokens are defined per tenant. Use with care!
*/
@Data
public static class Gatewaytoken {
public static class GatewayToken {
/**
* Gateway token based authentication enabled.

View File

@@ -9,17 +9,17 @@
*/
package org.eclipse.hawkbit.security.controller;
import static org.eclipse.hawkbit.audit.SecurityLogger.LOGGER;
import static org.eclipse.hawkbit.context.AccessContext.asTenant;
import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.AUTHENTICATION_HEADER_AUTHORITY_NAME;
import static org.eclipse.hawkbit.repository.helper.TenantConfigHelper.getAsSystem;
import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.AUTHENTICATION_HEADER_AUTHORITY;
import java.util.Arrays;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.helper.TenantConfigHelper;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
/**
@@ -29,62 +29,46 @@ import org.springframework.security.core.Authentication;
@Slf4j
public class SecurityHeaderAuthenticator extends Authenticator.AbstractAuthenticator {
private static final Logger LOG_SECURITY_AUTH = LoggerFactory.getLogger("server-security.auth");
// e.g: X-Controller-Id: controller-1 or X-Subject-CN: controller-1
private final String controllerIdHeader;
// e.g.: X-Authority: X,Y or X-CA-Fingerprint-0: <e.g. SHA-256 fingerprint>
// could be used with one or multiple authorities that has confirmed the controller id:
// 1. <authority>=X,Y,Z -> comma separated list of authorities (could be single authority)
// 2. <authority>-0=X, <authority>-1=Y, .. ... until we get a null header
private final String authorityHeader;
private final String authoritiesSeparatorRegex;
// Example Headers with Cert Information
// Clientip: 217.24.201.180
// X-Forwarded-Proto: https
// X-Ssl-Client-Cn: my.name
// X-Ssl-Client-Dn: CN=my.name,CN=O,CN=R,CN=DE,CN=BOSCH,CN=pki,DC=bosch,DC=com
// X-Ssl-Client-Hash: 7f:87:cb:b5:9c:e0:c5:0a:1a:a6:57:69:0f:ca:0a:95
// X-Ssl-Client-Notafter: Dec 18 08:02:45 2017 GMT
// X-Ssl-Client-Notbefore: Dec 18 07:32:45 2014 GMT
// X-Ssl-Client-Verify: ok
// X-Ssl-Issuer: CN=Bosch-CA1-DE,CN=PKI,DC=Bosch,DC=com
// X-Ssl-Issuer-Dn-1: CN=Bosch-CA-DE,CN=PKI,DC=Bosch,DC=com
// X-Ssl-Issuer-Hash-1: ae:11:f5:6a:0a:e8:74:50:81:0e:0c:37:ec:c5:22:fc
private final String caCommonNameHeader;
// the X-Ssl-Issuer-Hash basic header: Contains the x509 fingerprint hash, this
// header exists multiple times in the request for all trusted chains.
private final String sslIssuerHashBasicHeader;
public SecurityHeaderAuthenticator(final String caCommonNameHeader, final String caAuthorityNameHeader) {
this.caCommonNameHeader = caCommonNameHeader;
this.sslIssuerHashBasicHeader = caAuthorityNameHeader;
public SecurityHeaderAuthenticator(final DdiSecurityProperties.Rp rp) {
this.controllerIdHeader = rp.getControllerIdHeader();
this.authorityHeader = rp.getAuthorityHeader();
this.authoritiesSeparatorRegex = rp.getAuthoritiesSeparatorRegex();
}
@Override
public Authentication authenticate(final ControllerSecurityToken controllerSecurityToken) {
// retrieve the common name header and the authority name header from the http request and combine them together
final String commonNameValue = controllerSecurityToken.getHeader(caCommonNameHeader);
if (commonNameValue == null) {
log.debug("The request doesn't contain the 'common name' header");
final String verifiedControllerId = controllerSecurityToken.getHeader(controllerIdHeader);
if (verifiedControllerId == null) {
log.debug("The request doesn't contain the '{}' header", controllerIdHeader);
return null;
}
if (!commonNameValue.equals(controllerSecurityToken.getControllerId())) {
log.debug("The request contains the 'common name' header but it doesn't match the controller id");
if (!verifiedControllerId.equals(controllerSecurityToken.getControllerId())) {
log.debug("The request contains the '{}' header but it doesn't match the controller id", controllerIdHeader);
return null;
}
if (!isEnabled(controllerSecurityToken)) {
if (!isEnabled(controllerSecurityToken)) { // in order to do not do calls to db - check after previous header checks
log.debug("The gateway header authentication is disabled");
return null;
}
final String sslIssuerHashValue = getIssuerHashHeader(
controllerSecurityToken,
asTenant(
controllerSecurityToken.getTenant(),
() -> TenantConfigHelper.getAsSystem(AUTHENTICATION_HEADER_AUTHORITY_NAME, String.class)));
if (sslIssuerHashValue == null) {
log.debug("The request contains the 'common name' header but trusted hash is not found");
final String tenant = controllerSecurityToken.getTenant();
if (verify(controllerSecurityToken, asTenant(tenant, () -> getAsSystem(AUTHENTICATION_HEADER_AUTHORITY, String.class)))) {
log.trace("Found trusted authority ****, using as credentials (tenant: {})", tenant);
return authenticatedController(tenant, verifiedControllerId);
} else {
return null;
}
if (log.isTraceEnabled()) {
log.debug("Found sslIssuerHash ****, using as credentials for tenant {}", controllerSecurityToken.getTenant());
}
return authenticatedController(controllerSecurityToken.getTenant(), commonNameValue);
}
@Override
@@ -98,28 +82,52 @@ public class SecurityHeaderAuthenticator extends Authenticator.AbstractAuthentic
}
/**
* Iterates over the {@link #sslIssuerHashBasicHeader} basic header {@code X-Ssl-Issuer-Hash-%d} and try to find the same hash as known.
* It's ok if we find the hash in any the trusted CA chain to accept this request for this tenant.
* Check {@link #authorityHeader} basic header or iterates over {@link #authorityHeader}-%d and try to find the same authority as trusted.
* It's ok if we find any authority (in headers, authenticated the controller) to accept this request for this tenant.
*/
@SuppressWarnings("java:S2629") // check if debug is enabled is maybe heavier then evaluation
private String getIssuerHashHeader(final ControllerSecurityToken controllerSecurityToken, final String knownIssuerHashes) {
// there may be several knownIssuerHashes configured for the tenant
final List<String> knownHashes = Arrays.stream(knownIssuerHashes.split("[;,]")).map(String::toLowerCase).toList();
@SuppressWarnings({ "java:S2629", "java:S135", "java:S3776" }) // check if debug is enabled is maybe heavier than evaluation, rest - fine
private boolean verify(final ControllerSecurityToken controllerSecurityToken, final String headerAuthority) {
// there may be several trusted authorities (headerAuthority config value) configured for the tenant
final List<String> trustedAuthorities = Arrays.stream(headerAuthority.split(authoritiesSeparatorRegex))
.map(String::toLowerCase).map(String::trim).toList();
// iterate over the headers until we get a null header.
String foundHash;
for (int iHeader = 1; (foundHash = controllerSecurityToken.getHeader(
String.format(sslIssuerHashBasicHeader, iHeader))) != null; iHeader++) {
if (knownHashes.contains(foundHash.toLowerCase())) {
if (log.isTraceEnabled()) {
log.trace("Found matching ssl issuer hash at position {}", iHeader);
boolean hasAuthorityHeader = false;
String matchingAuthority = null;
final String authorityHeaderValue = controllerSecurityToken.getHeader(authorityHeader);
if (authorityHeaderValue == null) {
// go for authority header prefixed iteration. iterate over the headers until we get a null header. Start from index 0 or 1
for (int i = 0; ; i++) {
final String authority = controllerSecurityToken.getHeader(authorityHeader + "-" + i);
if (authority == null) {
if (i != 0) {
break; // end of index iteration
} // if 0, try if start from 1
} else {
hasAuthorityHeader = true;
final String authorityLower = authority.toLowerCase();
if (trustedAuthorities.contains(authorityLower)) {
matchingAuthority = authorityLower;
break;
}
}
return foundHash.toLowerCase();
}
} else {
hasAuthorityHeader = true;
matchingAuthority = Arrays.stream(authorityHeaderValue.split(authoritiesSeparatorRegex))
.map(String::toLowerCase).map(String::trim)
.filter(trustedAuthorities::contains)
.findFirst().orElse(null);
}
LOG_SECURITY_AUTH.debug(
"Certificate request but no matching hash found in headers {} for common name {} in request",
sslIssuerHashBasicHeader, controllerSecurityToken.getHeader(caCommonNameHeader));
return null;
if (matchingAuthority == null) {
if (hasAuthorityHeader) {
LOGGER.debug("[SEC_HEADER_AUTH] Request has an authority header(s) but it doesn't match any trusted authority");
}
} else if (log.isTraceEnabled()) {
log.trace("Found matching authority {}", matchingAuthority);
}
return matchingAuthority != null;
}
}

View File

@@ -10,7 +10,7 @@
package org.eclipse.hawkbit.security.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.AUTHENTICATION_HEADER_AUTHORITY_NAME;
import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.AUTHENTICATION_HEADER_AUTHORITY;
import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.AUTHENTICATION_HEADER_ENABLED;
import static org.mockito.Mockito.when;
@@ -33,23 +33,23 @@ class SecurityHeaderAuthenticatorTest {
private static final String CA_COMMON_NAME = "ca-cn";
private static final String CA_COMMON_NAME_VALUE = "box1";
private static final String X_SSL_ISSUER_HASH_1 = "X-Ssl-Issuer-Hash-1";
private static final String X_AUTHORITY_1 = "X-Authority-1";
private static final String SINGLE_HASH = "hash1";
private static final String SECOND_HASH = "hash2";
private static final String THIRD_HASH = "hash3";
private static final String UNKNOWN_HASH = "unknown";
private static final String SINGLE_AUTHORITY = "hash1";
private static final String SECOND_AUTHORITY = "hash2";
private static final String THIRD_AUTHORITY = "hash3";
private static final String UNKNOWN_AUTHORITY = "unknown";
private static final String MULTI_HASH = "HASH1;hash2,HASH3,HASH1";
private static final String MULTI_AUTHORITY = "HASH1;hash2,HASH3,HASH1";
private static final TenantConfigurationValue<String> CONFIG_VALUE_SINGLE_HASH = TenantConfigurationValue
.<String> builder().value(SINGLE_HASH).build();
private static final TenantConfigurationValue<String> CONFIG_VALUE_MULTI_HASH = TenantConfigurationValue
.<String> builder().value(MULTI_HASH).build();
private static final TenantConfigurationValue<Boolean> CONFIG_VALUE_ENABLED = TenantConfigurationValue
.<Boolean> builder().value(true).build();
private static final TenantConfigurationValue<Boolean> CONFIG_VALUE_DISABLED = TenantConfigurationValue
.<Boolean> builder().value(false).build();
private static final TenantConfigurationValue<String> CONFIG_VALUE_SINGLE_AUTHORITY = TenantConfigurationValue.<String> builder()
.value(SINGLE_AUTHORITY).build();
private static final TenantConfigurationValue<String> CONFIG_VALUE_MULTI_AUTHORITY = TenantConfigurationValue.<String> builder()
.value(MULTI_AUTHORITY).build();
private static final TenantConfigurationValue<Boolean> CONFIG_VALUE_ENABLED = TenantConfigurationValue.<Boolean> builder()
.value(true).build();
private static final TenantConfigurationValue<Boolean> CONFIG_VALUE_DISABLED = TenantConfigurationValue.<Boolean> builder()
.value(false).build();
private Authenticator authenticator;
@@ -59,17 +59,19 @@ class SecurityHeaderAuthenticatorTest {
@BeforeEach
void before() {
TenantConfigHelper.setTenantConfigurationManagement(tenantConfigurationManagementMock);
authenticator = new SecurityHeaderAuthenticator(CA_COMMON_NAME, "X-Ssl-Issuer-Hash-%d");
final DdiSecurityProperties.Rp rp = new DdiSecurityProperties.Rp();
rp.setControllerIdHeader(CA_COMMON_NAME);
authenticator = new SecurityHeaderAuthenticator(rp);
}
/**
* Tests successful authentication with multiple a single hashes
*/
@Test
void testWithSingleKnownHash() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SINGLE_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_AUTHORITY_NAME, String.class))
.thenReturn(CONFIG_VALUE_SINGLE_HASH);
void testWithSingleTrustedAuthority() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SINGLE_AUTHORITY);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_AUTHORITY, String.class))
.thenReturn(CONFIG_VALUE_SINGLE_AUTHORITY);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
@@ -82,19 +84,19 @@ class SecurityHeaderAuthenticatorTest {
* Tests successful authentication with multiple hashes
*/
@Test
void testWithMultipleKnownHashes() {
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_AUTHORITY_NAME, String.class))
.thenReturn(CONFIG_VALUE_MULTI_HASH);
void testWithMultipleTrustedAuthority() {
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_AUTHORITY, String.class))
.thenReturn(CONFIG_VALUE_MULTI_AUTHORITY);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(prepareSecurityToken(SINGLE_HASH)))
assertThat(authenticator.authenticate(prepareSecurityToken(SINGLE_AUTHORITY)))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
assertThat(authenticator.authenticate(prepareSecurityToken(SECOND_HASH)))
assertThat(authenticator.authenticate(prepareSecurityToken(SECOND_AUTHORITY)))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
assertThat(authenticator.authenticate(prepareSecurityToken(THIRD_HASH)))
assertThat(authenticator.authenticate(prepareSecurityToken(THIRD_AUTHORITY)))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
}
@@ -103,10 +105,10 @@ class SecurityHeaderAuthenticatorTest {
* Tests that if the hash is unknown, the authentication fails
*/
@Test
void testWithUnknownHash() {
final ControllerSecurityToken securityToken = prepareSecurityToken(UNKNOWN_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_AUTHORITY_NAME, String.class))
.thenReturn(CONFIG_VALUE_MULTI_HASH);
void testWithUnTrustedAuthority() {
final ControllerSecurityToken securityToken = prepareSecurityToken(UNKNOWN_AUTHORITY);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_AUTHORITY, String.class))
.thenReturn(CONFIG_VALUE_MULTI_AUTHORITY);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
@@ -120,7 +122,7 @@ class SecurityHeaderAuthenticatorTest {
void testWithNonMatchingCN() {
final ControllerSecurityToken securityToken = new ControllerSecurityToken("DEFAULT", "otherControllerID");
securityToken.putHeader(CA_COMMON_NAME, CA_COMMON_NAME_VALUE);
securityToken.putHeader(X_SSL_ISSUER_HASH_1, SINGLE_HASH);
securityToken.putHeader(X_AUTHORITY_1, SINGLE_AUTHORITY);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
@@ -137,10 +139,9 @@ class SecurityHeaderAuthenticatorTest {
* Tests that if disabled, the authentication fails
*/
@Test
void testWithSingleKnownHashButDisabled() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SINGLE_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_DISABLED);
void testWithSingleTrustedAuthorityButDisabled() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SINGLE_AUTHORITY);
when(tenantConfigurationManagementMock.getConfigurationValue(AUTHENTICATION_HEADER_ENABLED, Boolean.class)).thenReturn(CONFIG_VALUE_DISABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
@@ -148,7 +149,7 @@ class SecurityHeaderAuthenticatorTest {
private static ControllerSecurityToken prepareSecurityToken(final String issuerHashHeaderValue) {
final ControllerSecurityToken securityToken = new ControllerSecurityToken("DEFAULT", CA_COMMON_NAME_VALUE);
securityToken.putHeader(CA_COMMON_NAME, CA_COMMON_NAME_VALUE);
securityToken.putHeader(X_SSL_ISSUER_HASH_1, issuerHashHeaderValue);
securityToken.putHeader(X_AUTHORITY_1, issuerHashHeaderValue);
return securityToken;
}
}