Skip to content
Snippets Groups Projects
Commit 9c1a8dea authored by Sebastian Bergandy's avatar Sebastian Bergandy :keyboard:
Browse files

Merge branch 'OZG-7232-smart-documents-client-certificate-auth' into 'main'

OZG-7232 SmartDocuments zertifikatbasierte Authentifizierung

See merge request !4
parents 6fa17973 3b5827bb
Branches
Tags
1 merge request!4OZG-7232 SmartDocuments zertifikatbasierte Authentifizierung
...@@ -25,6 +25,7 @@ package de.ozgcloud.document.bescheid.smartdocuments; ...@@ -25,6 +25,7 @@ package de.ozgcloud.document.bescheid.smartdocuments;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection; import java.util.Collection;
import java.util.Optional; import java.util.Optional;
...@@ -34,14 +35,15 @@ import javax.xml.xpath.XPathConstants; ...@@ -34,14 +35,15 @@ import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory; import javax.xml.xpath.XPathFactory;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.w3c.dom.Document; import org.w3c.dom.Document;
import org.w3c.dom.Text; import org.w3c.dom.Text;
import org.xml.sax.SAXException; import org.xml.sax.SAXException;
...@@ -69,7 +71,6 @@ import lombok.Getter; ...@@ -69,7 +71,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import reactor.core.publisher.Mono;
@Log4j2 @Log4j2
@Service @Service
...@@ -87,7 +88,7 @@ class SmartDocumentsBescheidRemoteService implements BescheidRemoteService { ...@@ -87,7 +88,7 @@ class SmartDocumentsBescheidRemoteService implements BescheidRemoteService {
private static final MediaType JSON_MEDIA_TYPE_FOR_SD = MediaType.APPLICATION_JSON_UTF8; private static final MediaType JSON_MEDIA_TYPE_FOR_SD = MediaType.APPLICATION_JSON_UTF8;
@Qualifier("smartDocuments") @Qualifier("smartDocuments")
private final WebClient smartDocumentsWebClient; private final RestClient smartDocumentsRestClient;
private final SmartDocumentsProperties properties; private final SmartDocumentsProperties properties;
...@@ -96,19 +97,24 @@ class SmartDocumentsBescheidRemoteService implements BescheidRemoteService { ...@@ -96,19 +97,24 @@ class SmartDocumentsBescheidRemoteService implements BescheidRemoteService {
var sdRequest = createRequest(request, vorgang); var sdRequest = createRequest(request, vorgang);
LOG.debug(() -> buildLogRequest(sdRequest)); LOG.debug(() -> buildLogRequest(sdRequest));
return smartDocumentsWebClient.post().accept(MediaType.APPLICATION_JSON) var response = smartDocumentsRestClient.post().accept(MediaType.APPLICATION_JSON)
.contentType(JSON_MEDIA_TYPE_FOR_SD) .contentType(JSON_MEDIA_TYPE_FOR_SD)
.bodyValue(sdRequest) .body(sdRequest)
.retrieve() .retrieve()
.onStatus(HttpStatusCode::is4xxClientError, this::handleClientError) .onStatus(HttpStatusCode::is4xxClientError, this::handleClientError)
.bodyToMono(SmartDocumentsResponse.class) .toEntity(SmartDocumentsResponse.class)
.map(response -> buildBescheid(request, response)) .getBody();
.block(); return buildBescheid(request, response);
} }
Mono<Throwable> handleClientError(ClientResponse response) { void handleClientError(HttpRequest request, ClientHttpResponse response) {
return response.body(BodyExtractors.toMono(String.class)) try {
.map(content -> new TechnicalException("Client-Error: " + content)); var responseBody = IOUtils.toString(response.getBody(), StandardCharsets.UTF_8);
throw new TechnicalException("Client-Error: " + response.getStatusCode() + " " + responseBody);
} catch (IOException e) {
throw new TechnicalException("Couldn't read response body", e);
}
} }
private String buildLogRequest(SmartDocumentsRequest request) { private String buildLogRequest(SmartDocumentsRequest request) {
......
...@@ -23,54 +23,100 @@ ...@@ -23,54 +23,100 @@
*/ */
package de.ozgcloud.document.bescheid.smartdocuments; package de.ozgcloud.document.bescheid.smartdocuments;
import org.springframework.beans.factory.annotation.Autowired; import java.util.Objects;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.CredentialsProvider;
import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.core5.http.HttpHost;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.ssl.NoSuchSslBundleException;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.ExchangeFilterFunctions; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.client.RestClient;
import reactor.netty.http.client.HttpClient; import lombok.AllArgsConstructor;
import reactor.netty.transport.ProxyProvider;
@AllArgsConstructor
@Configuration @Configuration
@ConditionalOnProperty("ozgcloud.bescheid.smart-documents.url") @ConditionalOnProperty("ozgcloud.bescheid.smart-documents.url")
class SmartDocumentsConfiguration { class SmartDocumentsConfiguration {
@Autowired private final SmartDocumentsProperties properties;
private SmartDocumentsProperties properties; private final SslBundles sslBundles;
@Bean("smartDocuments") @Bean("smartDocuments")
WebClient smartDocumentsWebClient() { RestClient smartDocumentsRestClient() {
ReactorClientHttpConnector connector = new ReactorClientHttpConnector(buildHttpClient()); return RestClient.builder()
.requestFactory(new HttpComponentsClientHttpRequestFactory(buildHttpClient()))
return WebClient.builder()
.baseUrl(properties.getUrl()) .baseUrl(properties.getUrl())
.filter(ExchangeFilterFunctions.basicAuthentication(properties.getBasicAuth().getUsername(), properties.getBasicAuth().getPassword())) .defaultHeaders(this::addBasicAuthenticationIfConfigured)
.clientConnector(connector)
.build(); .build();
} }
private HttpClient buildHttpClient() { CloseableHttpClient buildHttpClient() {
if (properties.getProxy() != null) { if (properties.getProxy() != null) {
return createProxyHttpClient(); return createHttpClientUsingProxy();
} else { }
return createNoProxyHttpClient(); return createNoProxyHttpClient();
} }
private HttpClientBuilder createDefaultHttpClientBuilder() {
return HttpClients.custom()
.setConnectionManager(createConnectionManagerWithClientCertificateIfConfigured());
}
CloseableHttpClient createNoProxyHttpClient() {
return createDefaultHttpClientBuilder().build();
}
CloseableHttpClient createHttpClientUsingProxy() {
return createDefaultHttpClientBuilder()
.setRoutePlanner(createProxyRoutePlanner())
.setDefaultCredentialsProvider(createProxyCredentialsProvider())
.build();
} }
private HttpClient createNoProxyHttpClient() { DefaultProxyRoutePlanner createProxyRoutePlanner() {
return HttpClient.create(); return new DefaultProxyRoutePlanner(new HttpHost(properties.getProxy().getHost(), properties.getProxy().getPort()));
} }
private HttpClient createProxyHttpClient() { private CredentialsProvider createProxyCredentialsProvider() {
return HttpClient.create() var host = properties.getProxy().getHost();
.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP) var port = properties.getProxy().getPort();
.host(properties.getProxy().getHost()) var username = properties.getProxy().getUsername();
.port(properties.getProxy().getPort()) var password = properties.getProxy().getPassword().toCharArray();
.username(properties.getProxy().getUsername())
.password(username -> properties.getProxy().getPassword())); return CredentialsProviderBuilder.create().add(new AuthScope(host, port), username, password).build();
}
void addBasicAuthenticationIfConfigured(HttpHeaders headers) {
if (Objects.nonNull(properties.getBasicAuth())) {
headers.setBasicAuth(properties.getBasicAuth().getUsername(), properties.getBasicAuth().getPassword());
}
}
HttpClientConnectionManager createConnectionManagerWithClientCertificateIfConfigured() {
var builder = PoolingHttpClientConnectionManagerBuilder.create();
try {
var sslBundleName = properties.getSslBundleName();
if (Objects.nonNull(sslBundleName)) {
builder.setTlsSocketStrategy(new DefaultClientTlsStrategy(sslBundles.getBundle(sslBundleName).createSslContext()));
}
return builder.build();
} catch (NoSuchSslBundleException e) {
return builder.build();
}
} }
} }
...@@ -52,10 +52,14 @@ public class SmartDocumentsProperties { ...@@ -52,10 +52,14 @@ public class SmartDocumentsProperties {
/** /**
* Credential for basic auth to the Smart Documents Server * Credential for basic auth to the Smart Documents Server
*/ */
@NotNull
@Valid @Valid
private UsernamePassword basicAuth; private UsernamePassword basicAuth;
/**
* Name of SSL bundle need for client certificate authentication.
*/
private String sslBundleName;
/** /**
* Smart Documents Template Group * Smart Documents Template Group
*/ */
......
package de.ozgcloud.document.bescheid.smartdocuments;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import javax.net.ssl.SSLContext;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHost;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Spy;
import org.springframework.boot.ssl.NoSuchSslBundleException;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.http.HttpHeaders;
import de.ozgcloud.document.bescheid.smartdocuments.SmartDocumentsProperties.ProxyConfiguration;
class SmartDocumentsConfigurationTest {
@Spy
@InjectMocks
private SmartDocumentsConfiguration smartDocumentsConfiguration;
@Mock
private SmartDocumentsProperties properties;
@Mock
private SslBundles sslBundles;
@Nested
class TestBuildHttpClient {
private final CloseableHttpClient proxyHttpClient = HttpClients.createDefault();
private final CloseableHttpClient nonProxyHttpClient = HttpClients.createDefault();
@Test
void shouldReturnProxyHttpClient() {
doReturn(proxyHttpClient).when(smartDocumentsConfiguration).createHttpClientUsingProxy();
when(properties.getProxy()).thenReturn(new ProxyConfiguration());
var httpClient = smartDocumentsConfiguration.buildHttpClient();
assertThat(httpClient).isSameAs(proxyHttpClient);
}
@Test
void shouldReturnNonProxyHttpClient() {
doReturn(nonProxyHttpClient).when(smartDocumentsConfiguration).createNoProxyHttpClient();
when(properties.getProxy()).thenReturn(null);
var httpClient = smartDocumentsConfiguration.buildHttpClient();
assertThat(httpClient).isSameAs(nonProxyHttpClient);
}
}
@Nested
class TestCreateProxyRoutePlanner {
private final String host = "test-proxy.local";
private final int port = 8080;
private final ProxyConfiguration proxyConfiguration = new ProxyConfiguration();
@BeforeEach
void setUp() {
proxyConfiguration.setHost(host);
proxyConfiguration.setPort(port);
when(properties.getProxy()).thenReturn(proxyConfiguration);
}
@Test
void shouldCreateWithProxySettings() throws HttpException {
var routePlanner = smartDocumentsConfiguration.createProxyRoutePlanner();
var proxyHost = routePlanner.determineRoute(new HttpHost("http://any"), new HttpClientContext()).getProxyHost();
assertThat(proxyHost).extracting(
HttpHost::getHostName,
HttpHost::getPort
).containsExactly(host, port);
}
}
@Nested
class TestCreateProxyCredentialsProvider {
private final String user = "max";
private final String password = "max2";
private final String host = "test-proxy.local";
private final int port = 8080;
private final ProxyConfiguration proxyConfiguration = new ProxyConfiguration();
@BeforeEach
void setUp() {
proxyConfiguration.setHost(host);
proxyConfiguration.setPort(port);
proxyConfiguration.setUsername(user);
proxyConfiguration.setPassword(password);
when(properties.getProxy()).thenReturn(proxyConfiguration);
}
@Test
void shouldCreateWithUserAndPassword() {
var credentialsProvider = smartDocumentsConfiguration.createProxyCredentialsProvider();
var credentialsForProxy = (UsernamePasswordCredentials) credentialsProvider.getCredentials(new AuthScope(host, port), null);
assertThat(credentialsForProxy).extracting(
UsernamePasswordCredentials::getUserName,
UsernamePasswordCredentials::getUserPassword)
.containsExactly(user, password.toCharArray());
}
}
@Nested
class TestAddBasicAuthenticationIfConfigured {
@Test
void shouldNotAddBasicAuthentication() {
when(properties.getBasicAuth()).thenReturn(null);
var headers = new HttpHeaders();
smartDocumentsConfiguration.addBasicAuthenticationIfConfigured(headers);
assertThat(headers.get(HttpHeaders.AUTHORIZATION)).isNull();
}
@Test
void shouldAddBasicAuthentication() {
when(properties.getBasicAuth()).thenReturn(UsernamePasswordTestFactory.create());
var headers = new HttpHeaders();
smartDocumentsConfiguration.addBasicAuthenticationIfConfigured(headers);
assertThat(headers.get(HttpHeaders.AUTHORIZATION))
.isNotNull()
.containsExactly(UsernamePasswordTestFactory.getAsBasicAuthenticationHeader());
}
}
@Nested
class TestCreateConnectionManagerWithClientCertificateIfConfigured {
private MockedStatic<PoolingHttpClientConnectionManagerBuilder> staticBuilder;
@Mock
private PoolingHttpClientConnectionManagerBuilder builder;
@Mock
private SslBundle sslBundle;
@Mock
private SSLContext sslContext;
private final String bundleName = "smartdocuments_bundle";
private final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
@BeforeEach
void setUp() {
staticBuilder = mockStatic(PoolingHttpClientConnectionManagerBuilder.class);
staticBuilder.when(PoolingHttpClientConnectionManagerBuilder::create).thenReturn(builder);
when(builder.build()).thenReturn(connectionManager);
}
@AfterEach
void tearDown() {
staticBuilder.close();
}
@Test
void shouldCreateWithBuilder() {
smartDocumentsConfiguration.createConnectionManagerWithClientCertificateIfConfigured();
staticBuilder.verify(() -> PoolingHttpClientConnectionManagerBuilder.create());
}
@Nested
class OnMissingClientCertificateConfiguration {
@Test
void shouldCreateWithoutTlsStrategyOnMissingBundleName() {
when(properties.getSslBundleName()).thenReturn(null);
var created = smartDocumentsConfiguration.createConnectionManagerWithClientCertificateIfConfigured();
verify(builder, never()).setTlsSocketStrategy(any());
assertThat(created).isSameAs(connectionManager);
}
@Test
void shouldCreateWithoutTlsStrategyOnMissingBundleConfiguration() {
when(properties.getSslBundleName()).thenReturn(bundleName);
when(sslBundles.getBundle(anyString())).thenThrow(new NoSuchSslBundleException("some_bundle", "error message"));
var created = smartDocumentsConfiguration.createConnectionManagerWithClientCertificateIfConfigured();
verify(builder, never()).setTlsSocketStrategy(any());
assertThat(created).isSameAs(connectionManager);
}
}
@Nested
class OnExistingClientCertificateConfiguration {
@BeforeEach
void setUp() {
when(properties.getSslBundleName()).thenReturn(bundleName);
when(sslBundles.getBundle(any())).thenReturn(sslBundle);
when(sslBundle.createSslContext()).thenReturn(sslContext);
}
@Test
void shouldGetSslBundle() {
smartDocumentsConfiguration.createConnectionManagerWithClientCertificateIfConfigured();
verify(sslBundles).getBundle(bundleName);
}
@Test
void shouldCreateWithTlsStrategy() {
var created = smartDocumentsConfiguration.createConnectionManagerWithClientCertificateIfConfigured();
verify(builder).setTlsSocketStrategy(any());
assertThat(created).isSameAs(connectionManager);
}
}
}
}
\ No newline at end of file
package de.ozgcloud.document.bescheid.smartdocuments;
import org.bouncycastle.util.encoders.Base64;
import de.ozgcloud.document.bescheid.smartdocuments.SmartDocumentsProperties.UsernamePassword;
public class UsernamePasswordTestFactory {
public static final String USERNAME = "max";
public static final String PASSWORD = "max123";
public static UsernamePassword create() {
var usernamePassword = new UsernamePassword();
usernamePassword.setUsername(USERNAME);
usernamePassword.setPassword(PASSWORD);
return usernamePassword;
}
public static String getAsBasicAuthenticationHeader() {
var base64EncodedAuth = new String(Base64.encode(
(UsernamePasswordTestFactory.USERNAME + ":" + UsernamePasswordTestFactory.PASSWORD).getBytes()));
return "Basic " + base64EncodedAuth;
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment