Cleanup and improve the controller authentication (#2287)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-02-18 15:10:16 +02:00
committed by GitHub
parent cace8bd20e
commit 76ce1cf052
51 changed files with 942 additions and 1517 deletions

View File

@@ -0,0 +1,50 @@
<!--
Copyright (c) 2015 Bosch Software Innovations GmbH and others
This program and the accompanying materials are made
available under the terms of the Eclipse Public License 2.0
which is available at https://www.eclipse.org/legal/epl-2.0/
SPDX-License-Identifier: EPL-2.0
-->
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-ddi-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>hawkbit-ddi-security</artifactId>
<name>hawkBit :: Security :: Controller</name>
<dependencies>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- TEST -->
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-jpa</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,165 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.eclipse.hawkbit.security.DdiSecurityProperties;
import org.eclipse.hawkbit.util.UrlUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* An abstraction for all controller based security to parse the e.g. the tenant name from the URL and the controller ID from the URL to do
* security checks based on this information.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class AuthenticationFilters {
public static class GatewayTokenAuthenticationFilter extends AbstractAuthenticationFilter {
public GatewayTokenAuthenticationFilter(final GatewayTokenAuthenticator authenticator, final DdiSecurityProperties ddiSecurityProperties) {
super(authenticator, ddiSecurityProperties);
}
}
public static class SecurityHeaderAuthenticationFilter extends AbstractAuthenticationFilter {
public SecurityHeaderAuthenticationFilter(final SecurityHeaderAuthenticator authenticator, final DdiSecurityProperties ddiSecurityProperties) {
super(authenticator, ddiSecurityProperties);
}
}
public static class SecurityTokenAuthenticationFilter extends AbstractAuthenticationFilter {
public SecurityTokenAuthenticationFilter(final SecurityTokenAuthenticator authenticator, final DdiSecurityProperties ddiSecurityProperties) {
super(authenticator, ddiSecurityProperties);
}
}
/**
* An abstraction for all controller based security to parse the e.g. the tenant name from the URL and the controller ID from the URL to do
* security checks based on this information.
*/
public static abstract class AbstractAuthenticationFilter extends OncePerRequestFilter {
private static final String TENANT_PLACE_HOLDER = "tenant";
private static final String CONTROLLER_ID_PLACE_HOLDER = "controllerId";
/**
* requestURIPathPattern the request URI path pattern in ANT style containing the placeholder key for retrieving the principal from the URI
* request. e.g."/{tenant}/controller/v1/{controllerId}
*/
private static final String CONTROLLER_REQUEST_ANT_PATTERN =
"/{" + TENANT_PLACE_HOLDER + "}/controller/v1/{" + CONTROLLER_ID_PLACE_HOLDER + "}/**";
private static final String CONTROLLER_DL_REQUEST_ANT_PATTERN =
"/{" + TENANT_PLACE_HOLDER + "}/controller/artifacts/v1/**";
private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private final AntPathMatcher pathExtractor = new AntPathMatcher();
private final Authenticator authenticator;
private final List<String> authorizedSourceIps;
protected AbstractAuthenticationFilter(final Authenticator authenticator, final DdiSecurityProperties ddiSecurityProperties) {
this.authenticator = authenticator;
authorizedSourceIps = ddiSecurityProperties.getRp().getTrustedIPs();
}
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain)
throws IOException, ServletException {
if (acceptIPAddress(request)) {
final Authentication currentAuthentication = SecurityContextHolder.getContext().getAuthentication();
if (currentAuthentication == null || !currentAuthentication.isAuthenticated()) {
final ControllerSecurityToken securityToken = createTenantSecurityTokenVariables(request);
if (securityToken != null) {
final Authentication authentication = authenticator.authenticate(securityToken);
if (authentication != null) {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
this.securityContextHolderStrategy.setContext(context);
}
}
} else {
authenticator.log().trace("Request is already authenticated. Skip filter");
}
}
chain.doFilter(request, response);
}
/**
* Extracts tenant and controllerId from the request URI as path variables.
*
* @param request the Http request to extract the path variables.
* @return the extracted {@link ControllerSecurityToken} or {@code null} if the request does not match the pattern and no variables could be
* extracted
*/
private ControllerSecurityToken createTenantSecurityTokenVariables(final HttpServletRequest request) {
final String requestURI = request.getRequestURI();
if (pathExtractor.match(request.getContextPath() + CONTROLLER_REQUEST_ANT_PATTERN, requestURI)) {
authenticator.log().debug("retrieving principal from URI request {}", requestURI);
final Map<String, String> extractUriTemplateVariables = pathExtractor
.extractUriTemplateVariables(request.getContextPath() + CONTROLLER_REQUEST_ANT_PATTERN, requestURI);
final String controllerId = UrlUtils.decodeUriValue(extractUriTemplateVariables.get(CONTROLLER_ID_PLACE_HOLDER));
final String tenant = UrlUtils.decodeUriValue(extractUriTemplateVariables.get(TENANT_PLACE_HOLDER));
authenticator.log().trace("Parsed tenant {} and controllerId {} from path request {}", tenant, controllerId, requestURI);
return createTenantSecurityTokenVariables(request, tenant, controllerId);
} else if (pathExtractor.match(request.getContextPath() + CONTROLLER_DL_REQUEST_ANT_PATTERN, requestURI)) {
authenticator.log().debug("retrieving path variables from URI request {}", requestURI);
final Map<String, String> extractUriTemplateVariables = pathExtractor.extractUriTemplateVariables(
request.getContextPath() + CONTROLLER_DL_REQUEST_ANT_PATTERN, requestURI);
final String tenant = UrlUtils.decodeUriValue(extractUriTemplateVariables.get(TENANT_PLACE_HOLDER));
authenticator.log().trace("Parsed tenant {} from path request {}", tenant, requestURI);
return createTenantSecurityTokenVariables(request, tenant, "anonymous");
} else {
authenticator.log().trace("request {} does not match the path pattern {}, request gets ignored", requestURI, CONTROLLER_REQUEST_ANT_PATTERN);
return null;
}
}
private ControllerSecurityToken createTenantSecurityTokenVariables(
final HttpServletRequest request, final String tenant, final String controllerId) {
final ControllerSecurityToken securityToken = new ControllerSecurityToken(tenant, null, controllerId, null);
Collections.list(request.getHeaderNames()).forEach(header -> securityToken.putHeader(header, request.getHeader(header)));
return securityToken;
}
private boolean acceptIPAddress(final HttpServletRequest request) {
if (authorizedSourceIps == null) {
// no trusted IP check, because no authorizedSourceIPs configuration
return true;
}
final String remoteAddress = request.getRemoteAddr();
if (authorizedSourceIps.contains(remoteAddress)) {
// source ip matches the given pattern -> authenticated
return true;
} else {
authenticator.log().debug(
"The remote source IP address {} is not in the list of trusted IP addresses {}", remoteAddress, authorizedSourceIps);
return false;
}
}
}
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import lombok.EqualsAndHashCode;
import org.eclipse.hawkbit.im.authentication.SpPermission;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.TenantAware;
import org.eclipse.hawkbit.tenancy.TenantAwareAuthenticationDetails;
import org.slf4j.Logger;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Interface for Authentication mechanism.
*/
public interface Authenticator {
/**
* If the authentication mechanism is not enabled for the tenant - it just returns null.
* If the authentication mechanism is supported, the filter extracts from the security token the related credentials,
* validate them (do authenticate the caller).
* If validation / authentication is successful returns an authenticated authentication object. Otherwise,
* throws BadCredentialsException.
*
* @param controllerSecurityToken the securityToken
* @return the extracted tenant and controller id
*/
Authentication authenticate(ControllerSecurityToken controllerSecurityToken);
Logger log();
abstract class AbstractAuthenticator implements Authenticator {
protected final TenantConfigurationManagement tenantConfigurationManagement;
protected final TenantAware tenantAware;
protected final SystemSecurityContext systemSecurityContext;
private final TenantAware.TenantRunner<Boolean> isEnabledTenantRunner;
protected AbstractAuthenticator(
final TenantConfigurationManagement tenantConfigurationManagement,
final TenantAware tenantAware, final SystemSecurityContext systemSecurityContext) {
this.tenantConfigurationManagement = tenantConfigurationManagement;
this.tenantAware = tenantAware;
this.systemSecurityContext = systemSecurityContext;
isEnabledTenantRunner = () -> systemSecurityContext.runAsSystem(
() -> tenantConfigurationManagement.getConfigurationValue(getTenantConfigurationKey(), Boolean.class).getValue());
}
protected boolean isEnabled(final ControllerSecurityToken securityToken) {
return tenantAware.runAsTenant(securityToken.getTenant(), isEnabledTenantRunner);
}
protected abstract String getTenantConfigurationKey();
protected Authentication authenticatedController(final String tenant, final String controllerId) {
Objects.requireNonNull(tenant, "tenant must not be null");
Objects.requireNonNull(controllerId, "controllerId must not be null");
return new AuthenticatedController(tenant, controllerId);
}
@EqualsAndHashCode(callSuper = true)
private static class AuthenticatedController extends AbstractAuthenticationToken {
private static final Collection<GrantedAuthority> CONTROLLER_AUTHORITY =
List.of(new SimpleGrantedAuthority(SpPermission.SpringEvalExpressions.CONTROLLER_ROLE));
private final String controllerId;
AuthenticatedController(final String tenant, final String controllerId) {
super(CONTROLLER_AUTHORITY);
super.setDetails(new TenantAwareAuthenticationDetails(tenant, true));
this.controllerId = controllerId;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return controllerId;
}
}
}
}

View File

@@ -0,0 +1,99 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import java.util.Map;
import java.util.TreeMap;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* JSON representation to authenticate a tenant.
*/
@Data
@JsonInclude(Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public class ControllerSecurityToken {
public static final String AUTHORIZATION_HEADER = "Authorization";
@JsonProperty
private final Long tenantId;
@JsonProperty
private final Long targetId;
@JsonProperty
private final String controllerId;
@JsonProperty
private String tenant;
@JsonProperty
private Map<String, String> headers;
/**
* Constructor.
*
* @param tenant the tenant for the security token
* @param tenantId alternative tenant identification by technical ID
* @param controllerId the ID of the controller for the security token
* @param targetId alternative target identification by technical ID
*/
@JsonCreator
public ControllerSecurityToken(
@JsonProperty("tenant") final String tenant,
@JsonProperty("tenantId") final Long tenantId, @JsonProperty("controllerId") final String controllerId,
@JsonProperty("targetId") final Long targetId) {
this.tenant = tenant;
this.tenantId = tenantId;
this.controllerId = controllerId;
this.targetId = targetId;
}
/**
* Constructor.
*
* @param tenant the tenant for the security token
* @param controllerId the ID of the controller for the security token
*/
public ControllerSecurityToken(final String tenant, final String controllerId) {
this(tenant, null, controllerId, null);
}
/**
* Gets a header value.
*
* @param name of header
* @return the value
*/
public String getHeader(final String name) {
if (headers == null) {
return null;
}
return headers.get(name);
}
/**
* Associates the specified header value with the specified name.
*
* @param name of the header
* @param value of the header
*/
public void putHeader(final String name, final String value) {
if (headers == null) {
headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
}
headers.put(name, value);
}
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.TenantAware;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.slf4j.Logger;
import org.springframework.security.core.Authentication;
/**
* An authenticator which extracts (if enabled through configuration) the possibility to authenticate a target based through
* a gateway security token. This is commonly used for targets connected indirectly via a gateway. This gateway controls multiple targets
* under the gateway security token which can be set via the {@code Authorization} header.
* <p>
* {@code Example Header: Authorization: GatewayToken 5d8fSD54fdsFG98DDsa.}
*/
@Slf4j
public class GatewayTokenAuthenticator extends Authenticator.AbstractAuthenticator {
public static final String GATEWAY_SECURITY_TOKEN_AUTH_SCHEME = "GatewayToken ";
private static final int OFFSET_GATEWAY_TOKEN = GATEWAY_SECURITY_TOKEN_AUTH_SCHEME.length();
private final TenantAware.TenantRunner<String> gatewaySecurityTokenKeyConfigRunner;
public GatewayTokenAuthenticator(
final TenantConfigurationManagement tenantConfigurationManagement, final TenantAware tenantAware,
final SystemSecurityContext systemSecurityContext) {
super(tenantConfigurationManagement, tenantAware, systemSecurityContext);
gatewaySecurityTokenKeyConfigRunner = () -> {
log.trace("retrieving configuration value for configuration key {}",
TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY);
return systemSecurityContext
.runAsSystem(() -> tenantConfigurationManagement
.getConfigurationValue(TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY, String.class)
.getValue());
};
}
@Override
public Authentication authenticate(final ControllerSecurityToken controllerSecurityToken) {
final String authHeader = controllerSecurityToken.getHeader(ControllerSecurityToken.AUTHORIZATION_HEADER);
if (authHeader == null) {
log.debug("The request doesn't contain the 'authorization' header");
return null;
} else if (!authHeader.startsWith(GATEWAY_SECURITY_TOKEN_AUTH_SCHEME)) {
log.debug("The request contains the 'authorization' header but it doesn't start with '{}'", GATEWAY_SECURITY_TOKEN_AUTH_SCHEME);
return null;
}
if (!isEnabled(controllerSecurityToken)) {
log.debug("The gateway token authentication is disabled");
return null;
}
log.debug("Found 'authorization' header starting with '{}'", GATEWAY_SECURITY_TOKEN_AUTH_SCHEME);
final String presentedToken = authHeader.substring(OFFSET_GATEWAY_TOKEN);
// validate if the presented token is the same as the gateway token
return presentedToken.equals(tenantAware.runAsTenant(controllerSecurityToken.getTenant(), gatewaySecurityTokenKeyConfigRunner))
? authenticatedController(controllerSecurityToken.getTenant(), controllerSecurityToken.getControllerId()) : null;
}
@Override
public Logger log() {
return log;
}
@Override
protected String getTenantConfigurationKey() {
return TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED;
}
}

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import java.util.Arrays;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.TenantAware;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
/**
* An authenticator which extracts the principal from a request URI and the credential from a request header in a the
* {@link ControllerSecurityToken}.
*/
@Slf4j
public class SecurityHeaderAuthenticator extends Authenticator.AbstractAuthenticator {
private static final Logger LOG_SECURITY_AUTH = LoggerFactory.getLogger("server-security.authentication");
// 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;
private final TenantAware.TenantRunner<String> sslIssuerNameConfigTenantRunner;
public SecurityHeaderAuthenticator(
final TenantConfigurationManagement tenantConfigurationManagement, final TenantAware tenantAware,
final SystemSecurityContext systemSecurityContext,
final String caCommonNameHeader, final String caAuthorityNameHeader) {
super(tenantConfigurationManagement, tenantAware, systemSecurityContext);
this.caCommonNameHeader = caCommonNameHeader;
this.sslIssuerHashBasicHeader = caAuthorityNameHeader;
sslIssuerNameConfigTenantRunner = () -> systemSecurityContext.runAsSystem(
() -> tenantConfigurationManagement.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_AUTHORITY_NAME, String.class).getValue());
}
@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");
return null;
}
if (!commonNameValue.equals(controllerSecurityToken.getControllerId())) {
log.debug("The request contains the 'common name' header but it doesn't match the controller id");
return null;
}
if (!isEnabled(controllerSecurityToken)) {
log.debug("The gateway header authentication is disabled");
return null;
}
final String sslIssuerHashValue = getIssuerHashHeader(
controllerSecurityToken,
tenantAware.runAsTenant(controllerSecurityToken.getTenant(), sslIssuerNameConfigTenantRunner));
if (sslIssuerHashValue == null) {
log.debug("The request contains the 'common name' header but trusted hash is not found");
return null;
}
if (log.isTraceEnabled()) {
log.debug("Found sslIssuerHash ****, using as credentials for tenant {}", controllerSecurityToken.getTenant());
}
return authenticatedController(controllerSecurityToken.getTenant(), commonNameValue);
}
@Override
protected String getTenantConfigurationKey() {
return TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_ENABLED;
}
@Override
public Logger log() {
return log;
}
/**
* 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.
*/
@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();
// 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);
}
return foundHash.toLowerCase();
}
}
LOG_SECURITY_AUTH.debug(
"Certificate request but no matching hash found in headers {} for common name {} in request",
sslIssuerHashBasicHeader, controllerSecurityToken.getHeader(caCommonNameHeader));
return null;
}
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.ControllerManagement;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.TenantAware;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.slf4j.Logger;
import org.springframework.security.core.Authentication;
/**
* An authenticator which extracts (if enabled through configuration) the possibility to authenticate a target based on
* its target security-token with the {@code Authorization} HTTP header.
* <p>
* {@code Example Header: Authorization: TargetToken 5d8fSD54fdsFG98DDsa.}
*/
@Slf4j
public class SecurityTokenAuthenticator extends Authenticator.AbstractAuthenticator {
public static final String TARGET_SECURITY_TOKEN_AUTH_SCHEME = "TargetToken ";
private static final int OFFSET_TARGET_TOKEN = TARGET_SECURITY_TOKEN_AUTH_SCHEME.length();
private final ControllerManagement controllerManagement;
public SecurityTokenAuthenticator(
final TenantConfigurationManagement tenantConfigurationManagement, final TenantAware tenantAware,
final SystemSecurityContext systemSecurityContext,
final ControllerManagement controllerManagement) {
super(tenantConfigurationManagement, tenantAware, systemSecurityContext);
this.controllerManagement = controllerManagement;
}
@Override
public Authentication authenticate(final ControllerSecurityToken controllerSecurityToken) {
final String authHeader = controllerSecurityToken.getHeader(ControllerSecurityToken.AUTHORIZATION_HEADER);
if (authHeader == null) {
log.debug("The request doesn't contain the 'authorization' header");
return null;
} else if (!authHeader.startsWith(TARGET_SECURITY_TOKEN_AUTH_SCHEME)) {
log.debug("The request contains the 'authorization' header but it doesn't start with '{}'", TARGET_SECURITY_TOKEN_AUTH_SCHEME);
return null;
}
if (!isEnabled(controllerSecurityToken)) {
log.debug("The target security token authentication is disabled");
return null;
}
log.debug("Found 'authorization' header starting with '{}'", TARGET_SECURITY_TOKEN_AUTH_SCHEME);
final String presentedToken = authHeader.substring(OFFSET_TARGET_TOKEN);
return systemSecurityContext.runAsSystemAsTenant(() -> controllerSecurityToken.getTargetId() != null
? controllerManagement.get(controllerSecurityToken.getTargetId())
: controllerManagement.getByControllerId(controllerSecurityToken.getControllerId()),
controllerSecurityToken.getTenant())
// validate if the presented token is the same as the one set for the target
.filter(target -> presentedToken.equals(target.getSecurityToken()))
.map(target -> authenticatedController(controllerSecurityToken.getTenant(), target.getControllerId()))
.orElse(null);
}
@Override
public Logger log() {
return log;
}
@Override
protected String getTenantConfigurationKey() {
return TenantConfigurationKey.AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED;
}
}

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import io.qameta.allure.Description;
import io.qameta.allure.Feature;
import io.qameta.allure.Story;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.model.TenantConfigurationValue;
import org.eclipse.hawkbit.security.SecurityContextSerializer;
import org.eclipse.hawkbit.security.SecurityContextTenantAware;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.UserAuthoritiesResolver;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@Feature("Unit Tests - Security")
@Story("Gateway token authentication")
@ExtendWith(MockitoExtension.class)
class GatewayTokenAuthenticatorTest {
private static final String CONTROLLER_ID = "controllerId_gwtoken";
private static final String GATEWAY_TOKEN = "test-gw-token";
private static final String UNKNOWN_TOKEN = "unknown";
private static final TenantConfigurationValue<String> CONFIG_VALUE_GW_TOKEN = TenantConfigurationValue
.<String> builder().value(GATEWAY_TOKEN).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;
@Mock
private TenantConfigurationManagement tenantConfigurationManagementMock;
@Mock
private UserAuthoritiesResolver authoritiesResolver;
@Mock
private SecurityContextSerializer securityContextSerializer;
@BeforeEach
void before() {
final SecurityContextTenantAware tenantAware = new SecurityContextTenantAware(authoritiesResolver, securityContextSerializer);
authenticator = new GatewayTokenAuthenticator(
tenantConfigurationManagementMock, tenantAware,
new SystemSecurityContext(tenantAware));
}
@Test
@Description("Tests successful authentication with gateway token")
void testWithGwToken() {
final ControllerSecurityToken securityToken = prepareSecurityToken(GATEWAY_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY, String.class))
.thenReturn(CONFIG_VALUE_GW_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(securityToken))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CONTROLLER_ID);
}
@Test
@Description("Tests that if gateway token doesn't match, the authentication fails")
void testWithBadGwToken() {
final ControllerSecurityToken securityToken = prepareSecurityToken(UNKNOWN_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY, String.class))
.thenReturn(CONFIG_VALUE_GW_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
@Test
@Description("Tests that if gateway token miss, the authentication fails")
void testWithoutGwToken() {
assertThat(authenticator.authenticate(new ControllerSecurityToken("DEFAULT", CONTROLLER_ID))).isNull();
}
@Test
@Description("Tests that if disabled, the authentication fails")
void testWithGwTokenButDisabled() {
final ControllerSecurityToken securityToken = prepareSecurityToken(GATEWAY_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_DISABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
private static ControllerSecurityToken prepareSecurityToken(final String gwToken) {
final ControllerSecurityToken securityToken = new ControllerSecurityToken("DEFAULT", CONTROLLER_ID);
securityToken.putHeader(ControllerSecurityToken.AUTHORIZATION_HEADER, GatewayTokenAuthenticator.GATEWAY_SECURITY_TOKEN_AUTH_SCHEME + gwToken);
return securityToken;
}
}

View File

@@ -0,0 +1,159 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import io.qameta.allure.Description;
import io.qameta.allure.Feature;
import io.qameta.allure.Story;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.model.TenantConfigurationValue;
import org.eclipse.hawkbit.security.SecurityContextSerializer;
import org.eclipse.hawkbit.security.SecurityContextTenantAware;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.UserAuthoritiesResolver;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@Feature("Unit Tests - Security")
@Story("Security header authenticator")
@ExtendWith(MockitoExtension.class)
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 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 MULTI_HASH = "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 Authenticator authenticator;
@Mock
private TenantConfigurationManagement tenantConfigurationManagementMock;
@Mock
private UserAuthoritiesResolver authoritiesResolver;
@Mock
private SecurityContextSerializer securityContextSerializer;
@BeforeEach
void before() {
final SecurityContextTenantAware tenantAware = new SecurityContextTenantAware(authoritiesResolver, securityContextSerializer);
authenticator = new SecurityHeaderAuthenticator(
tenantConfigurationManagementMock, tenantAware,
new SystemSecurityContext(tenantAware), CA_COMMON_NAME, "X-Ssl-Issuer-Hash-%d"
);
}
@Test
@Description("Tests successful authentication with multiple a single hashes")
void testWithSingleKnownHash() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SINGLE_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_AUTHORITY_NAME, String.class))
.thenReturn(CONFIG_VALUE_SINGLE_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(securityToken))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
}
@Test
@Description("Tests successful authentication with multiple hashes")
void testWithMultipleKnownHashes() {
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_AUTHORITY_NAME, String.class))
.thenReturn(CONFIG_VALUE_MULTI_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(prepareSecurityToken(SINGLE_HASH)))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
assertThat(authenticator.authenticate(prepareSecurityToken(SECOND_HASH)))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
assertThat(authenticator.authenticate(prepareSecurityToken(THIRD_HASH)))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CA_COMMON_NAME_VALUE);
}
@Test
@Description("Tests that if the hash is unknown, the authentication fails")
void testWithUnknownHash() {
final ControllerSecurityToken securityToken = prepareSecurityToken(UNKNOWN_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_AUTHORITY_NAME, String.class))
.thenReturn(CONFIG_VALUE_MULTI_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
@Test
@Description("Tests that if CN doesn't match the CN in the security token, the authentication fails")
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);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
@Test
@Description("Tests that if the hash miss, the authentication fails")
void testWithoutHash() {
assertThat(authenticator.authenticate(new ControllerSecurityToken("DEFAULT", CA_COMMON_NAME_VALUE))).isNull();
}
@Test
@Description("Tests that if disabled, the authentication fails")
void testWithSingleKnownHashButDisabled() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SINGLE_HASH);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_HEADER_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_DISABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
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);
return securityToken;
}
}

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.security.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.Optional;
import io.qameta.allure.Description;
import io.qameta.allure.Feature;
import io.qameta.allure.Story;
import org.eclipse.hawkbit.repository.ControllerManagement;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.model.Target;
import org.eclipse.hawkbit.repository.model.TenantConfigurationValue;
import org.eclipse.hawkbit.security.SecurityContextSerializer;
import org.eclipse.hawkbit.security.SecurityContextTenantAware;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.UserAuthoritiesResolver;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@Feature("Unit Tests - Security")
@Story("Gateway token authentication")
@ExtendWith(MockitoExtension.class)
class SecurityTokenAuthenticatorTest {
private static final String CONTROLLER_ID = "controllerId_gwtoken";
private static final String SECURITY_TOKEN = "test-sec-token";
private static final String UNKNOWN_TOKEN = "unknown";
private static final TenantConfigurationValue<String> CONFIG_VALUE_GW_TOKEN = TenantConfigurationValue
.<String> builder().value(SECURITY_TOKEN).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;
@Mock
private TenantConfigurationManagement tenantConfigurationManagementMock;
@Mock
private ControllerManagement controllerManagementMock;
@Mock
private UserAuthoritiesResolver authoritiesResolver;
@Mock
private SecurityContextSerializer securityContextSerializer;
@BeforeEach
void before() {
final SecurityContextTenantAware tenantAware = new SecurityContextTenantAware(authoritiesResolver, securityContextSerializer);
authenticator = new SecurityTokenAuthenticator(
tenantConfigurationManagementMock, tenantAware,
new SystemSecurityContext(tenantAware), controllerManagementMock);
}
@Test
@Description("Tests successful authentication with gateway token")
void testWithSecToken() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SECURITY_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
final Target target = Mockito.mock(Target.class);
when(target.getControllerId()).thenReturn(CONTROLLER_ID);
when(target.getSecurityToken()).thenReturn(SECURITY_TOKEN);
when(controllerManagementMock.getByControllerId(CONTROLLER_ID)).thenReturn(Optional.of(target));
assertThat(authenticator.authenticate(securityToken))
.isNotNull()
.hasFieldOrPropertyWithValue("principal", CONTROLLER_ID);
}
@Test
@Description("Tests that if gateway token doesn't match, the authentication fails")
void testWithBadSecToken() {
final ControllerSecurityToken securityToken = prepareSecurityToken(UNKNOWN_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_ENABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
@Test
@Description("Tests that if gateway token miss, the authentication fails")
void testWithoutSecToken() {
assertThat(authenticator.authenticate(new ControllerSecurityToken("DEFAULT", CONTROLLER_ID))).isNull();
}
@Test
@Description("Tests that if disabled, the authentication fails")
void testWithSecTokenButDisabled() {
final ControllerSecurityToken securityToken = prepareSecurityToken(SECURITY_TOKEN);
when(tenantConfigurationManagementMock.getConfigurationValue(
TenantConfigurationKey.AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED, Boolean.class))
.thenReturn(CONFIG_VALUE_DISABLED);
assertThat(authenticator.authenticate(securityToken)).isNull();
}
private static ControllerSecurityToken prepareSecurityToken(final String secToken) {
final ControllerSecurityToken securityToken = new ControllerSecurityToken("DEFAULT", CONTROLLER_ID);
securityToken.putHeader(ControllerSecurityToken.AUTHORIZATION_HEADER, SecurityTokenAuthenticator.TARGET_SECURITY_TOKEN_AUTH_SCHEME + secToken);
return securityToken;
}
}