diff --git a/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsBescheidRemoteService.java b/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsBescheidRemoteService.java index 8a32e8a0fe7f0322f78843501acc28dd87c2e1be..02eae1754da169817e6371696bc1b814d34a1dc7 100644 --- a/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsBescheidRemoteService.java +++ b/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsBescheidRemoteService.java @@ -25,6 +25,7 @@ package de.ozgcloud.document.bescheid.smartdocuments; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Optional; @@ -34,14 +35,15 @@ import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; +import org.apache.commons.io.IOUtils; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.BodyExtractors; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.client.RestClient; import org.w3c.dom.Document; import org.w3c.dom.Text; import org.xml.sax.SAXException; @@ -69,7 +71,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import reactor.core.publisher.Mono; @Log4j2 @Service @@ -87,7 +88,7 @@ class SmartDocumentsBescheidRemoteService implements BescheidRemoteService { private static final MediaType JSON_MEDIA_TYPE_FOR_SD = MediaType.APPLICATION_JSON_UTF8; @Qualifier("smartDocuments") - private final WebClient smartDocumentsWebClient; + private final RestClient smartDocumentsRestClient; private final SmartDocumentsProperties properties; @@ -96,19 +97,24 @@ class SmartDocumentsBescheidRemoteService implements BescheidRemoteService { var sdRequest = createRequest(request, vorgang); 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) - .bodyValue(sdRequest) + .body(sdRequest) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, this::handleClientError) - .bodyToMono(SmartDocumentsResponse.class) - .map(response -> buildBescheid(request, response)) - .block(); + .toEntity(SmartDocumentsResponse.class) + .getBody(); + return buildBescheid(request, response); + } - Mono<Throwable> handleClientError(ClientResponse response) { - return response.body(BodyExtractors.toMono(String.class)) - .map(content -> new TechnicalException("Client-Error: " + content)); + void handleClientError(HttpRequest request, ClientHttpResponse response) { + try { + 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) { diff --git a/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfiguration.java b/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfiguration.java index 01255a15d544124d4e28975319b103614c2f52eb..6e141bb9ad78dee3c6dfb0e45f1607123548177c 100644 --- a/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfiguration.java +++ b/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfiguration.java @@ -23,54 +23,100 @@ */ 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.ssl.NoSuchSslBundleException; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.reactive.function.client.ExchangeFilterFunctions; -import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.http.HttpHeaders; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestClient; -import reactor.netty.http.client.HttpClient; -import reactor.netty.transport.ProxyProvider; +import lombok.AllArgsConstructor; +@AllArgsConstructor @Configuration @ConditionalOnProperty("ozgcloud.bescheid.smart-documents.url") class SmartDocumentsConfiguration { - @Autowired - private SmartDocumentsProperties properties; + private final SmartDocumentsProperties properties; + private final SslBundles sslBundles; @Bean("smartDocuments") - WebClient smartDocumentsWebClient() { - ReactorClientHttpConnector connector = new ReactorClientHttpConnector(buildHttpClient()); - - return WebClient.builder() + RestClient smartDocumentsRestClient() { + return RestClient.builder() + .requestFactory(new HttpComponentsClientHttpRequestFactory(buildHttpClient())) .baseUrl(properties.getUrl()) - .filter(ExchangeFilterFunctions.basicAuthentication(properties.getBasicAuth().getUsername(), properties.getBasicAuth().getPassword())) - .clientConnector(connector) + .defaultHeaders(this::addBasicAuthenticationIfConfigured) .build(); } - private HttpClient buildHttpClient() { + CloseableHttpClient buildHttpClient() { if (properties.getProxy() != null) { - return createProxyHttpClient(); - } else { - return createNoProxyHttpClient(); + return createHttpClientUsingProxy(); } + return createNoProxyHttpClient(); + } + + private HttpClientBuilder createDefaultHttpClientBuilder() { + return HttpClients.custom() + .setConnectionManager(createConnectionManagerWithClientCertificateIfConfigured()); + } + + CloseableHttpClient createNoProxyHttpClient() { + return createDefaultHttpClientBuilder().build(); + } + + CloseableHttpClient createHttpClientUsingProxy() { + return createDefaultHttpClientBuilder() + .setRoutePlanner(createProxyRoutePlanner()) + .setDefaultCredentialsProvider(createProxyCredentialsProvider()) + .build(); + } + + DefaultProxyRoutePlanner createProxyRoutePlanner() { + return new DefaultProxyRoutePlanner(new HttpHost(properties.getProxy().getHost(), properties.getProxy().getPort())); + } + + private CredentialsProvider createProxyCredentialsProvider() { + var host = properties.getProxy().getHost(); + var port = properties.getProxy().getPort(); + var username = properties.getProxy().getUsername(); + var password = properties.getProxy().getPassword().toCharArray(); + + return CredentialsProviderBuilder.create().add(new AuthScope(host, port), username, password).build(); } - private HttpClient createNoProxyHttpClient() { - return HttpClient.create(); + void addBasicAuthenticationIfConfigured(HttpHeaders headers) { + if (Objects.nonNull(properties.getBasicAuth())) { + headers.setBasicAuth(properties.getBasicAuth().getUsername(), properties.getBasicAuth().getPassword()); + } } - private HttpClient createProxyHttpClient() { - return HttpClient.create() - .proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP) - .host(properties.getProxy().getHost()) - .port(properties.getProxy().getPort()) - .username(properties.getProxy().getUsername()) - .password(username -> properties.getProxy().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(); + } } } diff --git a/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsProperties.java b/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsProperties.java index 6b0d37bfba52547a9d4de355903207ef286761fd..1c7839b2f12a7e7cb2ce5e66c9cc65228b96f7e2 100644 --- a/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsProperties.java +++ b/document-manager-server/src/main/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsProperties.java @@ -52,10 +52,14 @@ public class SmartDocumentsProperties { /** * Credential for basic auth to the Smart Documents Server */ - @NotNull @Valid private UsernamePassword basicAuth; + /** + * Name of SSL bundle need for client certificate authentication. + */ + private String sslBundleName; + /** * Smart Documents Template Group */ diff --git a/document-manager-server/src/test/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfigurationTest.java b/document-manager-server/src/test/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfigurationTest.java new file mode 100644 index 0000000000000000000000000000000000000000..a34c9da9a7197b175e397bf431d1297b12c3a647 --- /dev/null +++ b/document-manager-server/src/test/java/de/ozgcloud/document/bescheid/smartdocuments/SmartDocumentsConfigurationTest.java @@ -0,0 +1,240 @@ +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 diff --git a/document-manager-server/src/test/java/de/ozgcloud/document/bescheid/smartdocuments/UsernamePasswordTestFactory.java b/document-manager-server/src/test/java/de/ozgcloud/document/bescheid/smartdocuments/UsernamePasswordTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..7ef947a7ab23db9abbbe6306752617edee323423 --- /dev/null +++ b/document-manager-server/src/test/java/de/ozgcloud/document/bescheid/smartdocuments/UsernamePasswordTestFactory.java @@ -0,0 +1,26 @@ +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; + } + +}