diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..c1b4a2f --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Contributors to the GXF project +# +# SPDX-License-Identifier: Apache-2.0 + +name: Build Pipeline + +on: + push: + branches: [ "main" ] + tags: [ "v**" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Setup Gradle to generate and submit dependency graphs + uses: gradle/gradle-build-action@v2.9.0 + with: + dependency-graph: generate-and-submit + - name: Build with Gradle + run: ./gradlew build integrationTest bootBuildImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run sonar analysis with Gradle + run: ./gradlew testCodeCoverageReport integrationTestCodeCoverageReport sonar + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + - name: Publish Docker image + if: github.ref == 'refs/heads/main' || github.ref_type == 'tag' + run: ./gradlew bootBuildImage -PpublishImage + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..137069b --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/application/build.gradle.kts b/application/build.gradle.kts new file mode 100644 index 0000000..3c53a58 --- /dev/null +++ b/application/build.gradle.kts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id("org.springframework.boot") +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.kafka:spring-kafka") + implementation("com.gxf.utilities:kafka-azure-oauth:0.2") + implementation("com.microsoft.azure:msal4j:1.13.10") + implementation("org.apache.httpcomponents:httpclient:4.5.13") + implementation(kotlin("reflect")) + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") + + implementation("org.springframework:spring-aspects") + + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testImplementation("org.mockito:mockito-junit-jupiter") +} + +tasks.withType { + imageName.set("ghcr.io/osgp/gxf-soap-bridge:${version}") + if (project.hasProperty("publishImage")) { + publish.set(true) + docker { + publishRegistry { + username.set(System.getenv("GITHUB_ACTOR")) + password.set(System.getenv("GITHUB_TOKEN")) + } + } + } +} + +testing { + suites { + val integrationTest by registering(JvmTestSuite::class) { + useJUnitJupiter() + dependencies { + implementation(project()) + implementation("org.springframework.boot:spring-boot-starter-test") + implementation("org.springframework.kafka:spring-kafka-test") + implementation("org.assertj:assertj-core") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.wiremock:wiremock:3.3.1") + + implementation("org.testcontainers:kafka:1.17.6") + } + } + } +} diff --git a/application/src/integrationTest/kotlin/org/gxf/soapbridge/EndToEndTest.kt b/application/src/integrationTest/kotlin/org/gxf/soapbridge/EndToEndTest.kt new file mode 100644 index 0000000..c1a2325 --- /dev/null +++ b/application/src/integrationTest/kotlin/org/gxf/soapbridge/EndToEndTest.kt @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge + +import com.github.tomakehurst.wiremock.client.WireMock.* +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import com.github.tomakehurst.wiremock.junit5.WireMockExtension +import org.assertj.core.api.Assertions.assertThat +import org.gxf.soapbridge.application.factories.SslContextFactory +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.ComponentScan +import org.springframework.http.client.reactive.JdkClientHttpConnector +import org.springframework.kafka.test.context.EmbeddedKafka +import org.springframework.web.reactive.function.client.WebClient +import java.net.http.HttpClient +import java.time.Duration + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ComponentScan(basePackages = ["org.gxf.soapbridge"]) +@EmbeddedKafka(topics = ["requests", "responses"]) +class EndToEndTest( + @LocalServerPort private val soapPort: Int, + @Autowired private val sslContextFactory: SslContextFactory, +) { + private val proxyUrl = "https://localhost:$soapPort/proxy-server" + private val methodPath = "/someSoapMethod" + private val callUrl = "$proxyUrl$methodPath" + + @BeforeEach + fun setUp() { + wireMockExtension.stubFor( + post(methodPath) + .withRequestBody(equalToXml(soapBody)) + .willReturn( + ok().withBody(soapResponse) + ) + ) + } + + @Test + fun testRequestResponse() { + // Arrange an SSL context for organisation "testClient" using its client certificate + val sslContextForOrganisation = sslContextFactory.createSslContext("testClient") + val httpClient = HttpClient.newBuilder() + .sslContext(sslContextForOrganisation) + .build() + val webClient = WebClient.builder() + .clientConnector(JdkClientHttpConnector(httpClient)) + .build() + + // Act: send SOAP request and get the answer + val responseBody = webClient.post().uri(callUrl) + .bodyValue(soapBody) + .exchangeToMono { it.bodyToMono(String::class.java) } + .timeout(Duration.ofSeconds(10)) + .block() + + // Assert + assertThat(responseBody).isEqualTo(soapResponse) + } + + val soapBody = """ + + + + + + T + + + + """.trimIndent() + + val soapResponse = "Read This Fine Message" + + companion object { + @JvmField + @RegisterExtension + val wireMockExtension: WireMockExtension = + WireMockExtension.newInstance().options( + wireMockConfig() + .httpDisabled(true).httpsPort(8888) + .keystorePath("src/integrationTest/resources/proxy.keystore.jks") + .keystorePassword("123456") + .keyManagerPassword("123456") + .keystoreType("PKCS12") + .trustStorePath("src/integrationTest/resources/proxy.truststore.jks") + .trustStorePassword("123456") + .trustStoreType("PKCS12") + .needClientAuth(true) + ).build() + } +} diff --git a/application/src/integrationTest/kotlin/org/gxf/soapbridge/SoapBridgeApplicationTests.kt b/application/src/integrationTest/kotlin/org/gxf/soapbridge/SoapBridgeApplicationTests.kt new file mode 100644 index 0000000..66e040c --- /dev/null +++ b/application/src/integrationTest/kotlin/org/gxf/soapbridge/SoapBridgeApplicationTests.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.kafka.test.context.EmbeddedKafka + +@SpringBootTest +@EmbeddedKafka(topics = ["avroTopic"]) +class SoapBridgeApplicationTests { + + @Test + fun contextLoads() { + assertThat(true).`as` { "Application context loads" }.isTrue() + } + +} diff --git a/application/src/integrationTest/resources/application.yaml b/application/src/integrationTest/resources/application.yaml new file mode 100644 index 0000000..c46fea9 --- /dev/null +++ b/application/src/integrationTest/resources/application.yaml @@ -0,0 +1,64 @@ +spring: + kafka: + bootstrap-servers: ${spring.embedded.kafka.brokers} +server: + ssl: + enabled: true + protocol: TLS + client-auth: need + key-store: "classpath:proxy.keystore.jks" + key-store-password: 123456 + key-store-type: PKCS12 + key-alias: localhost + key-password: 123456 + trust-store: "classpath:proxy.truststore.jks" + trust-store-password: 123456 + trust-store-type: PKCS12 + +security: + key-store: + location: src/integrationTest/resources/organisations/ + password: 123456 + type: pkcs12 + trust-store: + location: src/integrationTest/resources/proxy.truststore.jks + password: 123456 + type: jks + signing: + key-type: RSA + sign-key-file: src/integrationTest/resources/sign-key.der + verify-key-file: src/integrationTest/resources/verify-key.der + provider: SunRsaSign + signature: SHA256withRSA + +kafka: + # short circuit configuration: topics are linked back to the same proxy instance + outgoing: + requests: + topic: requests + responses: + topic: responses + incoming: + requests: + topic: requests + concurrency: 1 + responses: + topic: responses + concurrency: 1 + +soap: + call-endpoint: + host: localhost + port: 8888 + protocol: https + hostname-verification-strategy: BROWSER_COMPATIBLE_HOSTNAMES + time-out: 45 + custom-timeouts: + SetScheduleRequest: 180 + GetStatusRequest: 120 + +logging: + level: + root: warn + org: + gxf: info diff --git a/application/src/integrationTest/resources/generate_certs.sh b/application/src/integrationTest/resources/generate_certs.sh new file mode 100755 index 0000000..5f7bc86 --- /dev/null +++ b/application/src/integrationTest/resources/generate_certs.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright Contributors to the GXF project +# +# SPDX-License-Identifier: Apache-2.0 + +set -x + +mkdir -p organisations + +# Clean up previous key/trust store +rm *.jks +rm organisations/* + + +PASSWORD=123456 +echo Generating proxy certificate +openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt -passout pass:$PASSWORD -subj "/C=NL/ST=Gelderland/L=Arnhem/O=Alliander/OU=IT/CN=soap-bridge-CA" +openssl req -new -newkey rsa:4096 -passout pass:$PASSWORD -keyout proxy.key -out proxy.csr -subj "/C=NL/ST=Gelderland/L=Arnhem/O=Alliander/OU=IT/CN=localhost" +openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in proxy.csr -out proxy.crt -days 3650 -CAcreateserial -passin pass:$PASSWORD -extfile localhost.ext +openssl pkcs12 -export -out proxy.p12 -name "localhost" -inkey proxy.key -in proxy.crt -passout pass:$PASSWORD -passin pass:$PASSWORD + +echo +echo +echo Creating proxy keystore +keytool -importkeystore -srckeystore proxy.p12 -srcstoretype PKCS12 -destkeystore proxy.keystore.jks -deststoretype PKCS12 -srcstorepass $PASSWORD -deststorepass $PASSWORD -noprompt + +echo +echo +echo Creating proxy truststore +keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore proxy.truststore.jks -srcstorepass $PASSWORD -deststorepass $PASSWORD + +echo +echo +echo Generating client certificate +openssl req -new -newkey rsa:4096 -nodes -keyout testClient.key -out testClient.csr -passout pass:$PASSWORD \ + -subj "/C=NL/ST=Gelderland/L=Arnhem/O=Alliander/OU=IT/CN=testClient" + + +openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in proxy.csr -out proxy.crt -days 3650 -CAcreateserial -passin pass:$PASSWORD -extfile localhost.ext +openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in testClient.csr -out testClient.crt -days 3650 -CAcreateserial -passin pass:$PASSWORD +openssl pkcs12 -export -out organisations/testClient.pfx -name "testClient" -inkey testClient.key -in testClient.crt -passout pass:$PASSWORD -passin pass:$PASSWORD + +echo Creating sign and verify keys +openssl genrsa -out signing.pem 2048 +openssl rsa -in signing.pem -pubout -outform DER -out verify-key.der +openssl pkcs8 -topk8 -inform PEM -outform DER -in signing.pem -out sign-key.der -nocrypt + +# Clean up intermediate files +rm *.crt *.key *.csr *.p12 *.pem diff --git a/application/src/integrationTest/resources/localhost.ext b/application/src/integrationTest/resources/localhost.ext new file mode 100644 index 0000000..45324cc --- /dev/null +++ b/application/src/integrationTest/resources/localhost.ext @@ -0,0 +1,5 @@ +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +subjectAltName = @alt_names +[alt_names] +DNS.1 = localhost diff --git a/application/src/integrationTest/resources/organisations/testClient.pfx b/application/src/integrationTest/resources/organisations/testClient.pfx new file mode 100644 index 0000000..b880a09 Binary files /dev/null and b/application/src/integrationTest/resources/organisations/testClient.pfx differ diff --git a/application/src/integrationTest/resources/proxy.keystore.jks b/application/src/integrationTest/resources/proxy.keystore.jks new file mode 100644 index 0000000..eae9f2c Binary files /dev/null and b/application/src/integrationTest/resources/proxy.keystore.jks differ diff --git a/application/src/integrationTest/resources/proxy.truststore.jks b/application/src/integrationTest/resources/proxy.truststore.jks new file mode 100644 index 0000000..d7da0d3 Binary files /dev/null and b/application/src/integrationTest/resources/proxy.truststore.jks differ diff --git a/application/src/integrationTest/resources/sign-key.der b/application/src/integrationTest/resources/sign-key.der new file mode 100644 index 0000000..af19a15 Binary files /dev/null and b/application/src/integrationTest/resources/sign-key.der differ diff --git a/application/src/integrationTest/resources/verify-key.der b/application/src/integrationTest/resources/verify-key.der new file mode 100644 index 0000000..7bda5f9 Binary files /dev/null and b/application/src/integrationTest/resources/verify-key.der differ diff --git a/application/src/main/java/org/gxf/soapbridge/application/configuration/SoapEndpointMapping.java b/application/src/main/java/org/gxf/soapbridge/application/configuration/SoapEndpointMapping.java new file mode 100644 index 0000000..81136a8 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/configuration/SoapEndpointMapping.java @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.application.configuration; + +import jakarta.servlet.http.HttpServletRequest; +import org.gxf.soapbridge.soap.endpoints.SoapEndpoint; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.handler.AbstractHandlerMapping; + +@Component +public class SoapEndpointMapping extends AbstractHandlerMapping { + private final SoapEndpoint soapEndpoint; + + public SoapEndpointMapping(final SoapEndpoint soapEndpoint) { + this.soapEndpoint = soapEndpoint; + } + + @Override + protected Object getHandlerInternal(@NotNull final HttpServletRequest request) { + return soapEndpoint; + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java b/application/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java new file mode 100644 index 0000000..c40078d --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.application.factories; + +import static org.gxf.soapbridge.configuration.properties.HostnameVerificationStrategy.ALLOW_ALL_HOSTNAMES; +import static org.gxf.soapbridge.configuration.properties.HostnameVerificationStrategy.BROWSER_COMPATIBLE_HOSTNAMES; + +import javax.net.ssl.HostnameVerifier; +import org.apache.http.conn.ssl.DefaultHostnameVerifier; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.gxf.soapbridge.configuration.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.springframework.stereotype.Component; + +@Component +public class HostnameVerifierFactory { + private final SoapConfigurationProperties soapConfiguration; + + public HostnameVerifierFactory(final SoapConfigurationProperties soapConfiguration) { + this.soapConfiguration = soapConfiguration; + } + + public HostnameVerifier getHostnameVerifier() throws ProxyServerException { + if (soapConfiguration.getHostnameVerificationStrategy() == ALLOW_ALL_HOSTNAMES) { + return new NoopHostnameVerifier(); + } else if (soapConfiguration.getHostnameVerificationStrategy() + == BROWSER_COMPATIBLE_HOSTNAMES) { + return new DefaultHostnameVerifier(); + } else { + throw new ProxyServerException("No hostname verification strategy set!"); + } + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java b/application/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java new file mode 100644 index 0000000..f6d81c8 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.factories; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import org.gxf.soapbridge.application.services.SslContextCacheService; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.gxf.soapbridge.soap.exceptions.UnableToCreateHttpsURLConnectionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** This {@link @Component} class can create {@link HttpsURLConnection} instances. */ +@Component +public class HttpsUrlConnectionFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpsUrlConnectionFactory.class); + + /** Cache of {@link SSLContext} instances used to obtain {@link HttpsURLConnection} instances. */ + private final SslContextCacheService sslContextCacheService; + + private final HostnameVerifierFactory hostnameVerifierFactory; + + public HttpsUrlConnectionFactory( + final SslContextCacheService sslContextCacheService, + final HostnameVerifierFactory hostnameVerifierFactory) { + this.sslContextCacheService = sslContextCacheService; + this.hostnameVerifierFactory = hostnameVerifierFactory; + } + + /** + * Create an {@link HttpsURLConnection} instance for the given arguments. + * + * @param uri The full URI of the end-point for this connection. + * @param host The host consists of domain name, server name or IP address followed by the port. + * Example: localhost:443 + * @param contentLength The content length of the SOAP payload which will be sent to the end-point + * using this connection. + * @param commonName The common name for the organization. + * @return Null in case the {@link SSLContext} cannot be created or fetched from the {@link + * SslContextCacheService}, or a configured and initialized {@link HttpsURLConnection} + * instance. + * @throws UnableToCreateHttpsURLConnectionException In case the configuration and/or + * initialization of an {@link HttpsURLConnection} instance fails. + */ + public HttpsURLConnection createConnection( + final String uri, final String host, final String contentLength, final String commonName) + throws UnableToCreateHttpsURLConnectionException { + try { + // Get SSLContext instance. + final SSLContext sslContext; + if (StringUtils.hasText(commonName)) { + sslContext = sslContextCacheService.getSslContextForCommonName(commonName); + } else { + sslContext = sslContextCacheService.getSslContext(); + } + // Check SSLContext instance. + if (sslContext == null) { + LOGGER.error( + "SSLContext instance is null. Unable to create HttpsURLConnection instance for uri: {}, host: {}, content length: {}, common name: {}", + uri, + host, + contentLength, + commonName); + return null; + } + // Create connection. + final HttpsURLConnection connection = (HttpsURLConnection) new URL(uri).openConnection(); + connection.setHostnameVerifier(hostnameVerifierFactory.getHostnameVerifier()); + connection.setSSLSocketFactory(sslContext.getSocketFactory()); + connection.setDoInput(true); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + connection.setRequestProperty( + HttpHeaders.ACCEPT_ENCODING, "text/xml;charset=" + StandardCharsets.UTF_8.name()); + connection.setRequestProperty(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); + connection.setRequestProperty( + HttpHeaders.CONTENT_TYPE, "text/xml;charset=" + StandardCharsets.UTF_8.name()); + connection.setRequestProperty(HttpHeaders.CONTENT_LENGTH, contentLength); + connection.setRequestProperty(HttpHeaders.HOST, host); + connection.setRequestProperty(HttpHeaders.CONNECTION, "Keep-Alive"); + connection.setRequestProperty( + HttpHeaders.USER_AGENT, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.155 Safari/537.36"); + LOGGER.debug( + "Created HttpsURLConnection instance for uri: {}, host: {}, content length: {}, common name: {}", + uri, + host, + contentLength, + commonName); + + return connection; + } catch (final IOException | ProxyServerException e) { + throw new UnableToCreateHttpsURLConnectionException("Creating connection failed.", e); + } + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java b/application/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java new file mode 100644 index 0000000..04fb91e --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.factories; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.security.KeyStore; +import javax.net.ssl.*; +import org.gxf.soapbridge.application.services.SslContextCacheService; +import org.gxf.soapbridge.configuration.properties.SecurityConfigurationProperties; +import org.gxf.soapbridge.configuration.properties.StoreConfigurationProperties; +import org.gxf.soapbridge.soap.exceptions.UnableToCreateKeyManagersException; +import org.gxf.soapbridge.soap.exceptions.UnableToCreateTrustManagersException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** This {@link @Component} class can create {@link SSLContext} instances. */ +@Component +public class SslContextFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(SslContextFactory.class); + + /** + * In order to create a proper instance of {@link SSLContext}, a protocol must be specified. Note + * that if {@link SSLContext#getDefault()} is used, the created instance does not support the + * custom {@link TrustManager} and {@link KeyManager} which are needed for this application!!! + */ + private static final String SSL_CONTEXT_PROTOCOL = "TLS"; + + private final SecurityConfigurationProperties securityConfiguration; + + /** + * Using the trust store, a {@link TrustManager} instance is created. This instance is created + * once, and assigned to this field. Subsequent calls to {@link + * SslContextCacheService#getSslContext()} will use the instance in order to create {@link + * SSLContext} instances. + */ + private TrustManager[] trustManagersForHttps; + + /** + * Using the trust store, a {@link TrustManager} instance is created. This instance is created + * once, and assigned to this field. Subsequent calls to {@link + * SslContextCacheService#getSslContextForCommonName(String)} will use the instance in order to + * create {@link SSLContext} instances. + */ + private TrustManager[] trustManagersForHttpsWithClientCertificate; + + public SslContextFactory(final SecurityConfigurationProperties securityConfiguration) { + this.securityConfiguration = securityConfiguration; + } + + /** + * Create an {@link SSLContext} instance. + * + * @return An {@link SSLContext} instance. + */ + public SSLContext createSslContext() { + return createSslContext(""); + } + + /** + * Create an {@link SSLContext} instance for the given common name. + * + * @param commonName The common name used to open the key store for an organization. May be an + * empty string if no client certificate is required. + * @return An {@link SSLContext} instance. + */ + public SSLContext createSslContext(final String commonName) { + if (commonName.isEmpty()) { + try { + // Only create an instance of the trust manager array once. + if (trustManagersForHttps == null) { + trustManagersForHttps = openTrustStoreAndCreateTrustManagers(); + } + // Use the trust manager to initialize an SSLContext instance. + final SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL); + sslContext.init(null, trustManagersForHttps, null); + LOGGER.debug("Created SSL context using trust manager for HTTPS"); + return sslContext; + } catch (final Exception e) { + LOGGER.error("Unexpected exception while creating SSL context using trust manager", e); + return null; + } + } else { + try { + // Only create an instance of the trust manager array once. + if (trustManagersForHttpsWithClientCertificate == null) { + trustManagersForHttpsWithClientCertificate = openTrustStoreAndCreateTrustManagers(); + } + final KeyManager[] keyManagerArray = openKeyStoreAndCreateKeyManagers(commonName); + // Use the key manager and trust manager to initialize an + // SSLContext instance. + final SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL); + sslContext.init(keyManagerArray, trustManagersForHttpsWithClientCertificate, null); + // It is not possible to set the SSLContext instance as default + // using: "SSLContext.setDefault(sslContext);" The SSl context + // is unique for each organization because each organization has + // their own *.pfx key store, which is the client certificate. + LOGGER.debug("Created SSL context using trust manager and key manager for HTTPS"); + return sslContext; + } catch (final Exception e) { + LOGGER.error( + "Unexpected exception while creating SSL context using trust manager and key manager", + e); + return null; + } + } + } + + private TrustManager[] openTrustStoreAndCreateTrustManagers() + throws UnableToCreateTrustManagersException { + final StoreConfigurationProperties trustStore = securityConfiguration.getTrustStore(); + LOGGER.debug("Opening trust store, pathToTrustStore: {}", trustStore.getLocation()); + try (final InputStream trustStream = new FileInputStream(trustStore.getLocation())) { + // Create trust manager using *.jks file. + final char[] trustPassword = trustStore.getPassword().toCharArray(); + final KeyStore trustStoreInstance = KeyStore.getInstance(trustStore.getType()); + trustStoreInstance.load(trustStream, trustPassword); + final TrustManagerFactory trustFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustFactory.init(trustStoreInstance); + return trustFactory.getTrustManagers(); + } catch (final Exception e) { + throw new UnableToCreateTrustManagersException( + "Unexpected exception while creating trust managers using trust store", e); + } + } + + private KeyManager[] openKeyStoreAndCreateKeyManagers(final String commonName) + throws UnableToCreateKeyManagersException { + // Assume the path does not have a trailing slash ( '/' ) and assume the + // file extension to be *.pfx. + // TODO improve null safety + final StoreConfigurationProperties keyStore = securityConfiguration.getKeyStore(); + final String pathToKeyStore = String.format("%s/%s.pfx", keyStore.getLocation(), commonName); + LOGGER.debug("Opening key store, pathToKeyStore: {}", pathToKeyStore); + try (final InputStream keyStoreStream = new FileInputStream(pathToKeyStore)) { + // Create key manager using *.pfx file. + final KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + final KeyStore keyStoreInstance = KeyStore.getInstance(keyStore.getType()); + final char[] keyPassword = keyStore.getPassword().toCharArray(); + keyStoreInstance.load(keyStoreStream, keyPassword); + keyManagerFactory.init(keyStoreInstance, keyPassword); + return keyManagerFactory.getKeyManagers(); + } catch (final Exception e) { + throw new UnableToCreateKeyManagersException( + "Unexpected exception while creating key managers using key store", e); + } + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java b/application/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java new file mode 100644 index 0000000..91a423f --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.soap.clients.Connection; +import org.gxf.soapbridge.soap.exceptions.ConnectionNotFoundInCacheException; +import org.gxf.soapbridge.valueobjects.ProxyServerResponseMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Service which handles SOAP responses from GXF. The SOAP response will be set for the connection + * which correlates with the connection-id. + */ +@Service +public class ClientCommunicationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(ClientCommunicationService.class); + + /** Service used to cache incoming connections from client applications. */ + private final ConnectionCacheService connectionCacheService; + + /** Service used to sign and/or verify the content of queue messages. */ + private final SigningService signingService; + + public ClientCommunicationService( + final ConnectionCacheService connectionCacheService, final SigningService signingService) { + this.connectionCacheService = connectionCacheService; + this.signingService = signingService; + } + + /** + * Process an incoming queue message. The content of the message has to be verified by the {@link + * SigningService}. Then a response from GXF will set for the pending connection from a client. + * + * @param proxyServerResponseMessage The incoming queue message to process. + */ + public void handleIncomingResponse(final ProxyServerResponseMessage proxyServerResponseMessage) { + final boolean isValid = + signingService.verifyContent( + proxyServerResponseMessage.constructString(), + proxyServerResponseMessage.getSignature()); + + try { + final Connection connection = + connectionCacheService.findConnection(proxyServerResponseMessage.getConnectionId()); + if (connection != null) { + if (isValid) { + LOGGER.debug("Connection valid, set SOAP response"); + connection.setSoapResponse(proxyServerResponseMessage.getSoapResponse()); + } else { + LOGGER.error("ProxyServerResponseMessage failed to pass security check."); + connection.setSoapResponse("Security check has failed."); + } + } else { + LOGGER.error("No connection found in cache for id."); + } + } catch (final ConnectionNotFoundInCacheException e) { + LOGGER.error("ConnectionNotFoundInCacheException", e); + } + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java b/application/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java new file mode 100644 index 0000000..3c2cda2 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.services; + +import java.util.concurrent.ConcurrentHashMap; +import org.gxf.soapbridge.soap.clients.Connection; +import org.gxf.soapbridge.soap.exceptions.ConnectionNotFoundInCacheException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** This {@link @Service} class caches connections from client applications. */ +@Service +public class ConnectionCacheService { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionCacheService.class); + + /** + * Map used to cache connections. The key is an uuid. The value is a {@link Connection} instance. + */ + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** + * Creates a connection and puts it in the cache. + * + * @return the created Connection + */ + public Connection cacheConnection() { + final Connection connection = new Connection(); + final String connectionId = connection.getConnectionId(); + LOGGER.debug("Caching connection with connectionId: {}", connectionId); + cache.put(connectionId, connection); + return connection; + } + + /** + * Get a {@link Connection} instance from the {@link ConnectionCacheService#cache}. + * + * @param connectionId The key for the {@link Connection} instance obtained by calling {@link + * ConnectionCacheService#cacheConnection()}. + * @return A {@link Connection} instance. + * @throws ConnectionNotFoundInCacheException In case the connection is not present in the {@link + * ConnectionCacheService#cache}. + */ + public Connection findConnection(final String connectionId) + throws ConnectionNotFoundInCacheException { + LOGGER.debug("Trying to find connection with connectionId: {}", connectionId); + final Connection connection = cache.get(connectionId); + if (connection == null) { + throw new ConnectionNotFoundInCacheException( + String.format("Unable to find connection for connectionId: %s", connectionId)); + } + return connection; + } + + /** + * Removes a {@link Connection} instance from the {@link ConnectionCacheService#cache}. + * + * @param connectionId The key for the {@link Connection} instance obtained by calling {@link + * ConnectionCacheService#cacheConnection()}. + */ + public void removeConnection(final String connectionId) { + LOGGER.debug("Removing connection with connectionId: {}", connectionId); + cache.remove(connectionId); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java b/application/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java new file mode 100644 index 0000000..7b3e027 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.soap.clients.SoapClient; +import org.gxf.soapbridge.valueobjects.ProxyServerRequestMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** Service which can send SOAP requests to GXF. */ +@Service +public class PlatformCommunicationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(PlatformCommunicationService.class); + + /** SOAP client used to sent request messages to GXF. */ + private final SoapClient soapClient; + + /** Service used to sign and/or verify the content of queue messages. */ + private final SigningService signingService; + + public PlatformCommunicationService( + final SoapClient soapClient, final SigningService signingService) { + this.soapClient = soapClient; + this.signingService = signingService; + } + + /** + * Process an incoming queue message. The content of the message has to be verified by the {@link + * SigningService}. Then a SOAP message can be sent to GXF using {@link SoapClient}. + * + * @param proxyServerRequestMessage The incoming queue message to process. + */ + public void handleIncomingRequest(final ProxyServerRequestMessage proxyServerRequestMessage) { + + final String proxyServerRequestMessageAsString = proxyServerRequestMessage.constructString(); + final String securityKey = proxyServerRequestMessage.getSignature(); + + final boolean isValid = + signingService.verifyContent(proxyServerRequestMessageAsString, securityKey); + if (!isValid) { + LOGGER.error("ProxyServerRequestMessage failed to pass security check."); + return; + } + + final String connectionId = proxyServerRequestMessage.getConnectionId(); + final String context = proxyServerRequestMessage.getContext(); + final String commonName = proxyServerRequestMessage.getCommonName(); + final String soapPayload = proxyServerRequestMessage.getSoapPayload(); + + final boolean result = soapClient.sendRequest(connectionId, context, commonName, soapPayload); + if (!result) { + LOGGER.error("Unsuccessful at sending request to platform."); + } else { + LOGGER.debug("Successfully sent response message to queue"); + } + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/services/SigningService.java b/application/src/main/java/org/gxf/soapbridge/application/services/SigningService.java new file mode 100644 index 0000000..8c275cc --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/services/SigningService.java @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.services; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Signature; +import java.util.HexFormat; +import org.gxf.soapbridge.configuration.properties.SecurityConfigurationProperties; +import org.gxf.soapbridge.configuration.properties.SigningConfigurationProperties; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * This {@link @Service} class can generate a signature for a given content, and verify the content + * using the signature. + */ +@Service +public class SigningService { + private final SigningConfigurationProperties signingConfiguration; + + private static final Logger LOGGER = LoggerFactory.getLogger(SigningService.class); + + public SigningService(final SecurityConfigurationProperties securityConfiguration) { + signingConfiguration = securityConfiguration.getSigning(); + } + + /** + * Using the given content, create a signature. The signature is converted from byte array to + * hexadecimal string. + * + * @param content The content to sign. + * @return The signature which can be used later to verify if the content is intact and unaltered. + * @throws ProxyServerException thrown when an error occurs during signing. + */ + public String signContent(final String content) throws ProxyServerException { + final byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + try { + final byte[] signature = createSignature(bytes); + LOGGER.debug("signature.length: {}", signature.length); + return HexFormat.of().formatHex(signature); + } catch (final GeneralSecurityException e) { + throw new ProxyServerException( + "Unexpected GeneralSecurityException when trying to sign the content", e); + } + } + + /** + * For the given content and security key, verify if the content is intact and unaltered. + * + * @param content The content to verify. + * @param securityKey The signature a.k.a. security key which was created using {@link + * SigningService#signContent(String)}. + * @return True when the verification succeeds. + */ + public boolean verifyContent(final String content, final String securityKey) { + final byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); + final byte[] securityKeyBytes = HexFormat.of().parseHex(securityKey); + LOGGER.debug("securityKeyBytes.length: {}", securityKeyBytes.length); + try { + return validateSignature(contentBytes, securityKeyBytes); + } catch (final GeneralSecurityException e) { + LOGGER.error( + "Unexpected GeneralSecurityException when trying to verify the content using the security key", + e); + return false; + } + } + + private byte[] createSignature(final byte[] message) throws GeneralSecurityException { + final Signature signatureBuilder = + Signature.getInstance( + signingConfiguration.getSignature(), signingConfiguration.getProvider()); + signatureBuilder.initSign(signingConfiguration.getSignKey()); + signatureBuilder.update(message); + return signatureBuilder.sign(); + } + + private boolean validateSignature(final byte[] message, final byte[] securityKey) + throws GeneralSecurityException { + final Signature signatureBuilder = + Signature.getInstance( + signingConfiguration.getSignature(), signingConfiguration.getProvider()); + signatureBuilder.initVerify(signingConfiguration.getVerifyKey()); + signatureBuilder.update(message); + return signatureBuilder.verify(securityKey); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java b/application/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java new file mode 100644 index 0000000..e3c088d --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.application.services; + +import java.util.concurrent.ConcurrentHashMap; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import org.gxf.soapbridge.application.factories.SslContextFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * This {@link @Service} class encapsulates the creation of a suitable {@link SSLContext} instance + * to use with a HTTPS connection, like {@link HttpsURLConnection} for example. Further, the created + * instance is cached using a {@link ConcurrentHashMap} in order to maintain performance even when + * many calls are handled simultaneously. The actual creation of {@link SSLContext} instances is + * delegated to {@link SslContextFactory} class. + */ +@Service +public class SslContextCacheService { + + private static final Logger LOGGER = LoggerFactory.getLogger(SslContextCacheService.class); + + /** + * Map used to cache {@link SSLContext} instances. The key is the common name for an organization, + * which is equal to the organization identification. + */ + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** Factory which assists in creating {@link SSLContext} instances. */ + private final SslContextFactory sslContextFactory; + + public SslContextCacheService(final SslContextFactory sslContextFactory) { + this.sslContextFactory = sslContextFactory; + } + + /** + * Creates a new {@link SSLContext} instance and caches it, or fetches an existing instance from + * the cache. + * + * @return A {@link SSLContext} instance. + */ + public SSLContext getSslContext() { + final String key = "SSLContextWithoutCommonName"; + if (cache.containsKey(key)) { + LOGGER.debug("Returning SSL Context from cache for key: {}", key); + return cache.get(key); + } else { + LOGGER.debug( + "Creating new SSL Context and putting the instance in the cache for key: {}", key); + final SSLContext sslContext = sslContextFactory.createSslContext(); + cache.put(key, sslContext); + return sslContext; + } + } + + /** + * Creates a new {@link SSLContext} instance for the given common name and caches it, or fetches + * an existing instance from the cache. + * + * @param commonName The common name which is used to look up a Personal Information Exchange + * (*.pfx) file that is used as key store. + * @return A {@link SSLContext} instance. + */ + public SSLContext getSslContextForCommonName(final String commonName) { + if (cache.containsKey(commonName)) { + LOGGER.debug("Returning SSL Context from cache for common name: {}", commonName); + return cache.get(commonName); + } else { + LOGGER.debug( + "Creating new SSL Context and putting the instance in the cache for common name: {}", + commonName); + final SSLContext sslContext = sslContextFactory.createSslContext(commonName); + cache.put(commonName, sslContext); + return sslContext; + } + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java b/application/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java new file mode 100644 index 0000000..1dcaf96 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.clients; + +import java.util.UUID; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +public class Connection { + + private String soapResponse; + + private final String connectionId; + + private final Semaphore responseReceived; + + public Connection() { + responseReceived = new Semaphore(0); + connectionId = UUID.randomUUID().toString(); + } + + public void setSoapResponse(final String soapResponse) { + this.soapResponse = soapResponse; + responseReceived(); + } + + public String getSoapResponse() { + return soapResponse; + } + + public String getConnectionId() { + return connectionId; + } + + /** + * Indicates the response for this connection has been received. + */ + public void responseReceived() { + responseReceived.release(); + } + + /** + * Waits for a response on this connection. + * + * @param timeout The number of seconds to wait for a response. + * + * @return true, if the response was received within @timeout seconds, + * false otherwise. + */ + public boolean waitForResponseReceived(final int timeout) throws InterruptedException { + return responseReceived.tryAcquire(timeout, TimeUnit.SECONDS); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java b/application/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java new file mode 100644 index 0000000..b4f0716 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.clients; + +import java.io.*; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.HttpsURLConnection; +import org.gxf.soapbridge.application.factories.HttpsUrlConnectionFactory; +import org.gxf.soapbridge.application.services.SigningService; +import org.gxf.soapbridge.configuration.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.configuration.properties.SoapEndpointConfiguration; +import org.gxf.soapbridge.kafka.senders.ProxyResponseKafkaSender; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.gxf.soapbridge.soap.exceptions.UnableToCreateHttpsURLConnectionException; +import org.gxf.soapbridge.valueobjects.ProxyServerResponseMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** This {@link @Component} class can send SOAP messages to the Platform. */ +@Component +public class SoapClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(SoapClient.class); + + /** Message sender to send messages to a queue. */ + private final ProxyResponseKafkaSender proxyReponseSender; + + private final SoapConfigurationProperties soapConfiguration; + + /** Factory which assist in creating {@link HttpsURLConnection} instances. */ + private final HttpsUrlConnectionFactory httpsUrlConnectionFactory; + + /** Service used to sign the content of a message. */ + private final SigningService signingService; + + public SoapClient( + final ProxyResponseKafkaSender proxyResponseSender, + final SoapConfigurationProperties soapConfiguration, + final HttpsUrlConnectionFactory httpsUrlConnectionFactory, + final SigningService signingService) { + this.proxyReponseSender = proxyResponseSender; + this.soapConfiguration = soapConfiguration; + this.httpsUrlConnectionFactory = httpsUrlConnectionFactory; + this.signingService = signingService; + } + + /** + * Send a request to the Platform. + * + * @param connectionId The connectionId for this connection. + * @param context The part of the URL indicating the SOAP web-service. + * @param commonName The common name (organisation identification). + * @param soapPayload The SOAP message to send to the platform. + * @return True if the request has been sent and the response has been received and written to a + * queue, false otherwise. + */ + public boolean sendRequest( + final String connectionId, + final String context, + final String commonName, + final String soapPayload) { + + HttpsURLConnection connection = null; + + try { + // Try to create a connection. + connection = createConnection(context, soapPayload, commonName); + if (connection == null) { + LOGGER.warn("Could not create connection for sending SOAP request."); + return false; + } + + // Send the SOAP payload to the server. + sendRequest(connection, soapPayload); + // Read the response. + LOGGER.debug("SOAP request sent, trying to read SOAP response..."); + final String soapResponse = readResponse(connection); + LOGGER.debug("SOAP response: {}", soapResponse); + // Always disconnect the connection. + connection.disconnect(); + + // Create proxy-server response message. + final ProxyServerResponseMessage responseMessage = + createProxyServerResponseMessage(connectionId, soapResponse); + + // Send queue message. + proxyReponseSender.send(responseMessage); + + return true; + } catch (final Exception e) { + if (connection != null) { + connection.disconnect(); + } + LOGGER.error("Unexpected exception while sending SOAP request", e); + return false; + } + } + + private HttpsURLConnection createConnection( + final String context, final String soapPayload, final String commonName) + throws UnableToCreateHttpsURLConnectionException { + final String contentLength = String.format("%d", soapPayload.length()); + + final SoapEndpointConfiguration callEndpoint = soapConfiguration.getCallEndpoint(); + + final String uri = callEndpoint.getUri().concat(context); + LOGGER.debug("Preparing to open connection for WEBAPP_REQUEST using URI: {}", uri); + return httpsUrlConnectionFactory.createConnection( + uri, callEndpoint.getHostAndPort(), contentLength, commonName); + } + + private void sendRequest(final HttpsURLConnection connection, final String soapPayLoad) + throws IOException { + try (final OutputStream outputStream = connection.getOutputStream(); + final OutputStreamWriter outputStreamWriter = + new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + outputStreamWriter.write(soapPayLoad); + outputStreamWriter.flush(); + } catch (final IOException e) { + LOGGER.debug("Rethrow IOException while sending SOAP request."); + throw e; + } + } + + private String readResponse(final HttpsURLConnection connection) throws IOException { + // Use a BufferedReader and an InputStreamReader configured with UTF-8 + // character encoding. This will ensure that the response from the + // Platform is read correctly. + try (final InputStream inputStream = getInputStream(connection); + final InputStreamReader inputStreamReader = + new InputStreamReader(inputStream, StandardCharsets.UTF_8); + final BufferedReader reader = new BufferedReader(inputStreamReader)) { + final StringBuilder response = new StringBuilder(); + String line = reader.readLine(); + while (line != null) { + response.append(line); + line = reader.readLine(); + } + return response.toString(); + } catch (final IOException e) { + LOGGER.debug("Rethrow IOException while reading SOAP response"); + throw e; + } + } + + private InputStream getInputStream(final HttpsURLConnection connection) throws IOException { + final InputStream inputStream; + + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + inputStream = connection.getInputStream(); + } else { + inputStream = connection.getErrorStream(); + } + + return inputStream; + } + + private ProxyServerResponseMessage createProxyServerResponseMessage( + final String connectionId, final String soapResponse) throws ProxyServerException { + final ProxyServerResponseMessage responseMessage = + new ProxyServerResponseMessage(connectionId, soapResponse); + final String signature = signingService.signContent(responseMessage.constructString()); + responseMessage.setSignature(signature); + return responseMessage; + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java b/application/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java new file mode 100644 index 0000000..88dfe75 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.endpoints; + +import static org.springframework.security.web.context.RequestAttributeSecurityContextRepository.DEFAULT_REQUEST_ATTR_NAME; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; +import org.gxf.soapbridge.application.services.ConnectionCacheService; +import org.gxf.soapbridge.application.services.SigningService; +import org.gxf.soapbridge.configuration.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.kafka.senders.ProxyRequestKafkaSender; +import org.gxf.soapbridge.soap.clients.Connection; +import org.gxf.soapbridge.soap.exceptions.ConnectionNotFoundInCacheException; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.gxf.soapbridge.valueobjects.ProxyServerRequestMessage; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; +import org.springframework.web.HttpRequestHandler; + +/** + * This {@link @Component} class is the endpoint for incoming SOAP requests from client + * applications. + */ +@Component +public class SoapEndpoint implements HttpRequestHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(SoapEndpoint.class); + + private static final String URL_PROXY_SERVER = "/proxy-server"; + + private static final int INVALID_CUSTOM_TIME_OUT = -1; + + /** Service used to cache incoming connections from client applications. */ + private final ConnectionCacheService connectionCacheService; + + private final SoapConfigurationProperties soapConfiguration; + + /** Message sender which can send a webapp request message to ActiveMQ. */ + private final ProxyRequestKafkaSender proxyRequestsSender; + + /** Service used to sign the content of a message. */ + private final SigningService signingService; + + /** Map of time-outs for specific functions. */ + private final Map customTimeOutsMap; + + public SoapEndpoint( + final ConnectionCacheService connectionCacheService, + final SoapConfigurationProperties soapConfiguration, + final ProxyRequestKafkaSender proxyRequestsSender, + final SigningService signingService) { + this.connectionCacheService = connectionCacheService; + this.soapConfiguration = soapConfiguration; + this.proxyRequestsSender = proxyRequestsSender; + this.signingService = signingService; + customTimeOutsMap = soapConfiguration.getCustomTimeouts(); + } + + /** Handles incoming SOAP requests. */ + @Override + public void handleRequest( + @NotNull final HttpServletRequest request, @NotNull final HttpServletResponse response) + throws ServletException, IOException { + + // For debugging, print all headers and parameters. + LOGGER.debug("Start of SoapEndpoint.handleRequest()"); + logHeaderValues(request); + logParameterValues(request); + + // Get the context, which should be an GXF SOAP end-point or a + // NOTIFICATION SOAP end-point. + final String context = getContextForRequestType(request); + LOGGER.debug("Context: {}", context); + + // Try to read the SOAP request. + final String soapPayload = readSoapPayload(request); + if (soapPayload == null) { + LOGGER.error("Unable to read SOAP request, returning 500."); + createErrorResponse(response); + return; + } + + String organisationName = null; + if (request.getAttribute(DEFAULT_REQUEST_ATTR_NAME) + instanceof final SecurityContext securityContext + && securityContext.getAuthentication().getPrincipal() instanceof final User organisation) { + organisationName = organisation.getUsername(); + } + if (organisationName == null) { + LOGGER.error("Unable to find client certificate, returning 500."); + createErrorResponse(response); + return; + } + + // Cache the incoming connection. + final Connection newConnection = connectionCacheService.cacheConnection(); + final String connectionId = newConnection.getConnectionId(); + + // Create a queue message and sign it. + final ProxyServerRequestMessage requestMessage = + new ProxyServerRequestMessage(connectionId, organisationName, context, soapPayload); + try { + final String signature = signingService.signContent(requestMessage.constructString()); + requestMessage.setSignature(signature); + } catch (final ProxyServerException e) { + LOGGER.error("Unable to sign message or set security key", e); + createErrorResponse(response); + connectionCacheService.removeConnection(connectionId); + return; + } + + final Integer customTimeOut = shouldUseCustomTimeOut(soapPayload); + final int timeout; + if (customTimeOut == INVALID_CUSTOM_TIME_OUT) { + timeout = soapConfiguration.getTimeout(); + LOGGER.debug("Using default timeout: {} seconds", timeout); + } else { + LOGGER.debug("Using custom timeout: {} seconds", customTimeOut); + timeout = customTimeOut; + } + + try { + proxyRequestsSender.send(requestMessage); + + final boolean responseReceived = newConnection.waitForResponseReceived(timeout); + if (!responseReceived) { + LOGGER.error("No response received within the specified timeout of {} seconds", timeout); + createErrorResponse(response); + connectionCacheService.removeConnection(connectionId); + return; + } + } catch (final InterruptedException e) { + LOGGER.error("Error while waiting for response", e); + createErrorResponse(response); + connectionCacheService.removeConnection(connectionId); + Thread.currentThread().interrupt(); + return; + } + + final String soap = readResponse(connectionId); + if (soap == null) { + LOGGER.error("Unable to read SOAP response: null"); + createErrorResponse(response); + } else { + LOGGER.debug("Request handled, trying to send response..."); + createSuccessFulResponse(response, soap); + } + + LOGGER.debug( + "End of SoapEndpoint.handleRequest() --> incoming request handled and response returned."); + } + + private void logHeaderValues(final HttpServletRequest request) { + if (LOGGER.isDebugEnabled()) { + for (final Enumeration headerNames = request.getHeaderNames(); + headerNames.hasMoreElements(); ) { + final String headerName = headerNames.nextElement(); + final String headerValue = request.getHeader(headerName); + LOGGER.debug(" header name: {} header value: {}", headerName, headerValue); + } + } + } + + private void logParameterValues(final HttpServletRequest request) { + if (LOGGER.isDebugEnabled()) { + for (final Enumeration parameterNames = request.getParameterNames(); + parameterNames.hasMoreElements(); ) { + final String parameterName = parameterNames.nextElement(); + final String valuesString = + Arrays.stream(request.getParameterValues(parameterName)) + .map(this::sanitize) + .collect(Collectors.joining(" ")); + LOGGER.debug(" parameter name: {} parameter value(s): {}", parameterName, valuesString); + } + } + } + + private String sanitize(final String value) { + return value.replace('\n', '_').replace('\r', '_').replace('\t', '_'); + } + + private String getContextForRequestType(final HttpServletRequest request) { + return request.getRequestURI().replace(URL_PROXY_SERVER, ""); + } + + private String readSoapPayload(final HttpServletRequest request) { + final StringBuilder stringBuilder = new StringBuilder(); + String line; + String soapPayload = null; + try { + final BufferedReader reader = request.getReader(); + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + soapPayload = stringBuilder.toString(); + LOGGER.debug(" payload: {}", soapPayload); + } catch (final Exception e) { + LOGGER.error("Unexpected error while reading request body", e); + } + return soapPayload; + } + + private Integer shouldUseCustomTimeOut(final String soapPayload) { + final Set keys = customTimeOutsMap.keySet(); + for (final String key : keys) { + if (soapPayload.contains(key)) { + return customTimeOutsMap.get(key); + } + } + return INVALID_CUSTOM_TIME_OUT; + } + + private String readResponse(final String connectionId) throws ServletException { + final String soap; + try { + final Connection connection = connectionCacheService.findConnection(connectionId); + soap = connection.getSoapResponse(); + connectionCacheService.removeConnection(connectionId); + } catch (final ConnectionNotFoundInCacheException e) { + LOGGER.error("Unexpected error while trying to find a cached connection", e); + throw new ServletException("Unable to obtain response"); + } + return soap; + } + + private void createErrorResponse(final HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + + private void createSuccessFulResponse(final HttpServletResponse response, final String soap) + throws IOException { + LOGGER.debug("Start - creating successful response"); + response.setStatus(HttpServletResponse.SC_OK); + response.addHeader("Keep-Alive", "timeout=5, max=100"); + response.addHeader(HttpHeaders.ACCEPT, "text/xml"); + response.addHeader(HttpHeaders.CONNECTION, "Keep-Alive"); + response.setContentType("text/xml; charset=" + StandardCharsets.UTF_8.name()); + response.getWriter().write(soap); + LOGGER.debug("End - creating successful response"); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java new file mode 100644 index 0000000..d3604fe --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.exceptions; + +import java.io.Serial; + +public class ConnectionNotFoundInCacheException extends ProxyServerException { + + /** Serial Version UID. */ + @Serial private static final long serialVersionUID = -858760086093512799L; + + public ConnectionNotFoundInCacheException(final String message) { + super(message); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java new file mode 100644 index 0000000..409af69 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.exceptions; + +import java.io.Serial; + +/** Base type for exceptions for proxy server component. */ +public class ProxyServerException extends Exception { + + /** Serial Version UID. */ + @Serial private static final long serialVersionUID = -8696835428244659385L; + + public ProxyServerException(final String message) { + super(message); + } + + public ProxyServerException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java new file mode 100644 index 0000000..28d5342 --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.exceptions; + +import java.io.Serial; + +public class UnableToCreateHttpsURLConnectionException extends ProxyServerException { + + @Serial private static final long serialVersionUID = -8807766325167125880L; + + public UnableToCreateHttpsURLConnectionException( + final String message, final Throwable throwable) { + super(message, throwable); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java new file mode 100644 index 0000000..c9d071d --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.exceptions; + +import java.io.Serial; + +public class UnableToCreateKeyManagersException extends ProxyServerException { + + /** Serial Version UID. */ + @Serial + private static final long serialVersionUID = -100586751704652623L; + + public UnableToCreateKeyManagersException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java new file mode 100644 index 0000000..747caed --- /dev/null +++ b/application/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.exceptions; + +import java.io.Serial; + +public class UnableToCreateTrustManagersException extends ProxyServerException { + + /** Serial Version UID. */ + @Serial + private static final long serialVersionUID = -855694158211466200L; + + public UnableToCreateTrustManagersException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/SoapBridgeApplication.kt b/application/src/main/kotlin/org/gxf/soapbridge/SoapBridgeApplication.kt new file mode 100644 index 0000000..93685e6 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/SoapBridgeApplication.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +@SpringBootApplication +@EnableWebMvc +@ConfigurationPropertiesScan +class SoapBridgeApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/configuration/SecurityConfiguration.kt b/application/src/main/kotlin/org/gxf/soapbridge/configuration/SecurityConfiguration.kt new file mode 100644 index 0000000..5a63b01 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/configuration/SecurityConfiguration.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.configuration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.SecurityFilterChain + +@Configuration +class SecurityConfiguration { + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain = + http.authorizeHttpRequests { + it + .anyRequest().authenticated() + }.x509 { + it + .subjectPrincipalRegex("CN=(.*?)(?:,|$)") + .userDetailsService(userDetailsService()) + }.csrf { it.disable() } + .build() + + + /** + * Uses the CN of the client certificate as the username for Springs Principal object + */ + @Bean + fun userDetailsService(): UserDetailsService = + UserDetailsService { username -> + return@UserDetailsService User( + username, "", emptyList() + ) + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/configuration/kafka/KafkaConfiguration.kt b/application/src/main/kotlin/org/gxf/soapbridge/configuration/kafka/KafkaConfiguration.kt new file mode 100644 index 0000000..8075fd1 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/configuration/kafka/KafkaConfiguration.kt @@ -0,0 +1,18 @@ +package org.gxf.soapbridge.configuration.kafka + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.util.backoff.FixedBackOff + +@Configuration +class KafkaConfiguration { + + /** + * Retry messages two times before giving up on the message + */ + @Bean + fun errorHandler(): DefaultErrorHandler { + return DefaultErrorHandler(FixedBackOff(0, 2L)) + } +} \ No newline at end of file diff --git a/application/src/main/kotlin/org/gxf/soapbridge/configuration/properties/SecurityConfigurationProperties.kt b/application/src/main/kotlin/org/gxf/soapbridge/configuration/properties/SecurityConfigurationProperties.kt new file mode 100644 index 0000000..2213040 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/configuration/properties/SecurityConfigurationProperties.kt @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.configuration.properties + +import mu.KotlinLogging +import org.springframework.boot.context.properties.ConfigurationProperties +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec + +@ConfigurationProperties("security") +class SecurityConfigurationProperties( + val keyStore: StoreConfigurationProperties?, + val trustStore: StoreConfigurationProperties, + val signing: SigningConfigurationProperties +) + +class StoreConfigurationProperties( + val location: String, + val password: String, + val type: String +) + + +class SigningConfigurationProperties( + keyType: String, + signKeyFile: String, + verifyKeyFile: String, + /** Indicates which provider is used for signing and verification. */ + val provider: String, + /** Indicates which signature is used for signing and verification. */ + val signature: String +) { + private val logger = KotlinLogging.logger {} + + /** Private key used for signing. */ + val signKey = createPrivateKey(signKeyFile, keyType, provider) + + /** Public key used for verification. */ + val verifyKey = createPublicKey(verifyKeyFile, keyType, provider) + + private fun createPrivateKey( + keyPath: String, keyType: String, provider: String + ): PrivateKey? { + return try { + val key = readKeyFromDisk(keyPath) + val privateKeySpec = PKCS8EncodedKeySpec(key) + val privateKeyFactory = KeyFactory.getInstance(keyType, provider) + privateKeyFactory.generatePrivate(privateKeySpec) + } catch (e: Exception) { + logger.error("Unexpected exception during private key creation", e) + null + } + } + + private fun createPublicKey( + keyPath: String, keyType: String, provider: String + ): PublicKey? { + return try { + val key = readKeyFromDisk(keyPath) + val publicKeySpec = X509EncodedKeySpec(key) + val publicKeyFactory = KeyFactory.getInstance(keyType, provider) + publicKeyFactory.generatePublic(publicKeySpec) + } catch (e: Exception) { + logger.error("Unexpected exception during public key creation", e) + null + } + } + + @Throws(IOException::class) + private fun readKeyFromDisk(keyPath: String): ByteArray = Files.readAllBytes(Paths.get(keyPath)) +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/configuration/properties/SoapConfigurationProperties.kt b/application/src/main/kotlin/org/gxf/soapbridge/configuration/properties/SoapConfigurationProperties.kt new file mode 100644 index 0000000..ebe4675 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/configuration/properties/SoapConfigurationProperties.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.configuration.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("soap") +class SoapConfigurationProperties( + val hostnameVerificationStrategy: HostnameVerificationStrategy, + /** + * Maximum number of seconds this {@link SoapEndpoint} will wait for a response from the other end + * before terminating the connection with the client application. + */ + val timeout: Int, + /** + * Timeouts for specific functions. + */ + val customTimeouts: Map = emptyMap(), + val callEndpoint: SoapEndpointConfiguration, +) + +enum class HostnameVerificationStrategy { + ALLOW_ALL_HOSTNAMES, BROWSER_COMPATIBLE_HOSTNAMES +} + +class SoapEndpointConfiguration( + host: String, + port: Int, + protocol: String +) { + // TODO Use java.net.URI class + val hostAndPort = "$host:$port" + val uri = "$protocol://${hostAndPort}" +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/exceptions/ProxyMessageException.kt b/application/src/main/kotlin/org/gxf/soapbridge/exceptions/ProxyMessageException.kt new file mode 100644 index 0000000..6a70b37 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/exceptions/ProxyMessageException.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.exceptions + + +class ProxyMessageException(message: String?) : Exception(message) diff --git a/application/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt b/application/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt new file mode 100644 index 0000000..a5aafa7 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.kafka.listeners + +import mu.KotlinLogging +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.gxf.soapbridge.application.services.PlatformCommunicationService +import org.gxf.soapbridge.valueobjects.ProxyServerRequestMessage +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component + +@Component +class ProxyRequestKafkaListener(private val platformCommunicationService: PlatformCommunicationService) { + private val logger = KotlinLogging.logger { } + + @KafkaListener( + id = "gxf-request-consumer", + topics = ["\${kafka.incoming.requests.topic}"], + concurrency = "\${kafka.incoming.requests.concurrency}" + ) + fun consume(record: ConsumerRecord) { + logger.debug { "Received request: ${record.key()}, ${record.value()}" } + val requestMessage = ProxyServerRequestMessage.createInstanceFromString(record.value()) + platformCommunicationService.handleIncomingRequest(requestMessage) + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt b/application/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt new file mode 100644 index 0000000..fe2d3e7 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.kafka.listeners + +import mu.KotlinLogging +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.gxf.soapbridge.application.services.ClientCommunicationService +import org.gxf.soapbridge.valueobjects.ProxyServerResponseMessage +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component + +@Component +class ProxyResponseKafkaListener(private val clientCommunicationService: ClientCommunicationService) { + private val logger = KotlinLogging.logger { } + + @KafkaListener( + id = "gxf-response-consumer", + topics = ["\${kafka.incoming.responses.topic}"], + concurrency = "\${kafka.incoming.responses.concurrency}" + ) + fun consume(record: ConsumerRecord) { + logger.debug { "Received response: ${record.key()}, ${record.value()}" } + val responseMessage = ProxyServerResponseMessage.createInstanceFromString(record.value()) + clientCommunicationService.handleIncomingResponse(responseMessage) + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt b/application/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt new file mode 100644 index 0000000..1561a83 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.kafka.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("kafka") +class TopicsConfigurationProperties( + val outgoing: OutgoingTopicsConfiguration, +) + +class OutgoingTopicsConfiguration( + val requests: OutgoingTopic, + val responses: OutgoingTopic +) + +class OutgoingTopic( + val topic: String +) \ No newline at end of file diff --git a/application/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt b/application/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt new file mode 100644 index 0000000..96fc475 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.kafka.senders + +import mu.KotlinLogging +import org.gxf.soapbridge.kafka.properties.TopicsConfigurationProperties +import org.gxf.soapbridge.valueobjects.ProxyServerRequestMessage +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component + +@Component +class ProxyRequestKafkaSender( + private val kafkaTemplate: KafkaTemplate, + topicConfiguration: TopicsConfigurationProperties +) { + private val logger = KotlinLogging.logger {} + + private val topic = topicConfiguration.outgoing.requests.topic + + fun send(requestMessage: ProxyServerRequestMessage) { + logger.debug { "SOAP payload: ${requestMessage.soapPayload} to $topic" } + kafkaTemplate.send(topic, requestMessage.constructSignedString()) + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt b/application/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt new file mode 100644 index 0000000..0841fd2 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.kafka.senders + +import mu.KotlinLogging +import org.gxf.soapbridge.kafka.properties.TopicsConfigurationProperties +import org.gxf.soapbridge.valueobjects.ProxyServerResponseMessage +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component + +@Component +class ProxyResponseKafkaSender( + private val kafkaTemplate: KafkaTemplate, + topicConfiguration: TopicsConfigurationProperties +) { + private val logger = KotlinLogging.logger {} + + private val topic = topicConfiguration.outgoing.responses.topic + + fun send(responseMessage: ProxyServerResponseMessage) { + logger.debug { "SOAP payload: ${responseMessage.soapResponse} to $topic" } + kafkaTemplate.send(topic, responseMessage.constructSignedString()) + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerBaseMessage.kt b/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerBaseMessage.kt new file mode 100644 index 0000000..364de6a --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerBaseMessage.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.valueobjects + +import java.util.Base64 + + +/** Base class for proxy-server messages. */ +abstract class ProxyServerBaseMessage(val connectionId: String) { + var signature: String? = null + + protected abstract fun getFieldsForMessage(): List + + /** Constructs a string separated by '~' from the fields of this instance. */ + fun constructString() = getFieldsForMessage().joinToString(SEPARATOR, postfix = SEPARATOR) + + // TODO possibly convert to jackson mapping + /** Constructs a string separated by '~' from the fields of this instance followed by the signature. */ + fun constructSignedString() = constructString() + signature + + companion object { + const val SEPARATOR = "~" + fun encode(input: String): String = Base64.getEncoder().encodeToString(input.toByteArray()) + fun decode(input: String?): String = String(Base64.getDecoder().decode(input)) + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerRequestMessage.kt b/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerRequestMessage.kt new file mode 100644 index 0000000..a7c07b0 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerRequestMessage.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.valueobjects + +import mu.KotlinLogging +import org.gxf.soapbridge.exceptions.ProxyMessageException +import java.util.* + + +class ProxyServerRequestMessage( + connectionId: String, + val commonName: String, + val context: String, + val soapPayload: String +) : ProxyServerBaseMessage(connectionId) { + + override fun getFieldsForMessage(): List = listOf( + connectionId, + encode(context), + encode(soapPayload), + encode(commonName) + ) + + companion object { + private val LOGGER = KotlinLogging.logger { } + + /** + * Constructs a ProxyServerRequestMessage instance from a string separated by '~'. + * + * @param string The input string. + * @return A ProxyServerRequestMessage instance. + * @throws ProxyMessageException + */ + @Throws(ProxyMessageException::class) + fun createInstanceFromString(string: String): ProxyServerRequestMessage { + val split = string.split(SEPARATOR) + val numTokens = split.size + LOGGER.debug { "split.length: ${numTokens}" } + if (numTokens < 4 || numTokens > 5) { + throw ProxyMessageException( + "Invalid number of tokens, not trying to create ProxyServerRequestMessage." + ) + } + if (LOGGER.isDebugEnabled) { + printValues(numTokens, split) + } + val connectionId = split[0] + val context = decode(split[1]) + val soapRequest = decode(split[2]) + val commonName: String + val signature: String + if (numTokens == 4) { + // No common name used. + commonName = "" + signature = split[3] + } else { + // Common name used. + commonName = decode(split[3]) + signature = split[4] + } + val proxyServerRequestMessage = + ProxyServerRequestMessage(connectionId, commonName, context, soapRequest) + proxyServerRequestMessage.signature = signature + return proxyServerRequestMessage + } + + private fun printValues(numTokens: Int, split: List) { + if (LOGGER.isDebugEnabled) { + LOGGER.debug("split[0] connection-id: {}", split[0]) + LOGGER.debug("split[2] context : {}", decode(split[1])) + LOGGER.debug("split[3] encoded soap-request length: {}", split[2]!!.length) + LOGGER.debug("split[3] soap-request : {}", decode(split[2])) + if (numTokens == 5) { + LOGGER.debug("split[4] security-key : {}", split[3]) + } else { + LOGGER.debug("split[4] common-name : {}", decode(split[3])) + LOGGER.debug("split[5] security-signature : {}", split[4]) + } + } + } + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerResponseMessage.kt b/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerResponseMessage.kt new file mode 100644 index 0000000..a6ad773 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/valueobjects/ProxyServerResponseMessage.kt @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 + +package org.gxf.soapbridge.valueobjects + +import mu.KotlinLogging +import org.gxf.soapbridge.exceptions.ProxyMessageException +import java.util.* + +class ProxyServerResponseMessage(connectionId: String, val soapResponse: String) : + ProxyServerBaseMessage(connectionId) { + + + /** Constructs a string separated by '~' from the fields of this instance. */ + override fun getFieldsForMessage(): List = listOf( + connectionId, + encode(soapResponse) + ) + + companion object { + val logger = KotlinLogging.logger { } + + /** + * Constructs a ProxyServerResponseMessage instance from a string separated by '~'. + * + * @param string The input string. + * @return A ProxyServerResponseMessage instance. + * @throws ProxyMessageException + */ + @Throws(ProxyMessageException::class) + fun createInstanceFromString(string: String): ProxyServerResponseMessage { + val split = string.split(SEPARATOR) + val numTokens = split.size + logger.debug { "split.length: ${numTokens}" } + if (numTokens < 3) { + throw ProxyMessageException( + "Invalid number of tokens, don't try to create ProxyServerResponseMessage" + ) + } + if (logger.isDebugEnabled) { + logger.debug("split[0] connection-id: {}", split[0]) + logger.debug("split[1] encoded soap-response length: {}", split[1].length) + logger.debug("split[1] soap-response: {}", decode(split[1])) + logger.debug("split[2] security-key : {}", split[2]) + } + val proxyServerResponseMessage = ProxyServerResponseMessage(split[0], decode(split[1])) + proxyServerResponseMessage.signature = split[2] + return proxyServerResponseMessage + } + } +} diff --git a/application/src/main/resources/application-dev.yml b/application/src/main/resources/application-dev.yml new file mode 100644 index 0000000..427a305 --- /dev/null +++ b/application/src/main/resources/application-dev.yml @@ -0,0 +1,48 @@ +logging: + level: + org: + gxf: + soapbridge: DEBUG + +spring: + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: gxf-proxy + +security: + key-store: + location: /etc/ssl/certs + password: 1234 + type: pkcs12 + trust-store: + location: /etc/ssl/certs/trust.jks + password: 123456 + type: jks + signing: + key-type: RSA + sign-key-file: /etc/ssl/certs/proxy-server/sign-key.der + verify-key-file: /etc/ssl/certs/proxy-server/verify-key.der + provider: SunRsaSign + signature: SHA256withRSA + +kafka: + outgoing: + requests: + topic: proxy-server-calls-requests + responses: + topic: proxy-server-calls-responses + incoming: + requests: + topic: proxy-server-notification-requests + concurrency: 1 + responses: + topic: proxy-server-notification-responses + concurrency: 1 + +soap: + call-endpoint: + host: localhost + port: 443 + protocol: https + time-out: 45 diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml new file mode 100644 index 0000000..ba729f1 --- /dev/null +++ b/application/src/main/resources/application.yaml @@ -0,0 +1,15 @@ +management: + endpoints: + web: + exposure: + include: prometheus + +soap: + hostname-verification-strategy: BROWSER_COMPATIBLE_HOSTNAMES + +kafka: + incoming: + requests: + concurrency: 1 + responses: + concurrency: 1 \ No newline at end of file diff --git a/application/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java b/application/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java new file mode 100644 index 0000000..0340a52 --- /dev/null +++ b/application/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright Contributors to the GXF project +// +// SPDX-License-Identifier: Apache-2.0 +package org.gxf.soapbridge.soap.clients; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import javax.net.ssl.HttpsURLConnection; +import org.gxf.soapbridge.application.factories.HttpsUrlConnectionFactory; +import org.gxf.soapbridge.application.services.SigningService; +import org.gxf.soapbridge.configuration.properties.HostnameVerificationStrategy; +import org.gxf.soapbridge.configuration.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.configuration.properties.SoapEndpointConfiguration; +import org.gxf.soapbridge.kafka.senders.ProxyResponseKafkaSender; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SoapClientTest { + + @Mock ProxyResponseKafkaSender proxyResponseKafkaSender; + @Mock HttpsUrlConnectionFactory httpsUrlConnectionFactory; + @Mock SigningService signingService; + + private final byte[] testContent = "test content".getBytes(StandardCharsets.UTF_8); + + @Spy + SoapConfigurationProperties soapConfigurationProperties = + new SoapConfigurationProperties( + HostnameVerificationStrategy.BROWSER_COMPATIBLE_HOSTNAMES, + 45, + new HashMap<>(), + new SoapEndpointConfiguration("localhost", 443, "https")); + + @InjectMocks SoapClient soapClient; + + @Test + void shouldSendSoapRequestAndKafkaResponse() throws Exception { + // arrange + final HttpsURLConnection connection = setupConnectionMock(); + Mockito.when( + httpsUrlConnectionFactory.createConnection( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString())) + .thenReturn(connection); + + // act + soapClient.sendRequest("connectionId", "context", "commonName", "payload"); + + // assert + Mockito.verify(connection).disconnect(); + } + + @Test + void shoudDisconnectWhenSoapRequestFails() throws Exception { + // arrange + final HttpsURLConnection connection = setupFailingConnectionMock(); + Mockito.when( + httpsUrlConnectionFactory.createConnection( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString())) + .thenReturn(connection); + + // act + soapClient.sendRequest("connectionId", "context", "commonName", "payload"); + + // assert + Mockito.verify(connection).disconnect(); + Mockito.verifyNoInteractions(proxyResponseKafkaSender); + } + + private HttpsURLConnection setupConnectionMock() throws Exception { + final HttpsURLConnection connection = Mockito.mock(HttpsURLConnection.class); + final InputStream inputStream = new ByteArrayInputStream(testContent); + Mockito.when(connection.getOutputStream()).thenReturn(Mockito.mock(OutputStream.class)); + Mockito.when(connection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + Mockito.when(connection.getInputStream()).thenReturn(inputStream); + return connection; + } + + private HttpsURLConnection setupFailingConnectionMock() throws Exception { + final HttpsURLConnection connection = Mockito.mock(HttpsURLConnection.class); + Mockito.when(connection.getOutputStream()).thenThrow(ConnectException.class); + return connection; + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5f2617f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,74 @@ +// Copyright 2023 Alliander N.V. + +import io.spring.gradle.dependencymanagement.internal.dsl.StandardDependencyManagementExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("org.springframework.boot") version "3.1.5" apply false + id("io.spring.dependency-management") version "1.1.3" apply false + kotlin("jvm") version "1.9.10" apply false + kotlin("plugin.spring") version "1.9.10" apply false + kotlin("plugin.jpa") version "1.9.10" apply false + id("com.github.davidmc24.gradle.plugin.avro") version "1.8.0" apply false + id("org.sonarqube") version "4.2.1.3168" + id("eclipse") +} + +version = System.getenv("GITHUB_REF_NAME")?.replace("/", "-")?.lowercase() ?: "develop" + +sonarqube { + properties { + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.projectKey", "OSGP_gxf-soap-bridge") + property("sonar.organization", "gxf") + } +} +tasks.sonar + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "org.jetbrains.kotlin.plugin.spring") + apply(plugin = "io.spring.dependency-management") + apply(plugin = "eclipse") + apply(plugin = "org.jetbrains.kotlin.plugin.jpa") + apply(plugin = "jacoco") + apply(plugin = "jacoco-report-aggregation") + + group = "org.gxf.soap-bridge" + version = rootProject.version + + repositories { + mavenCentral() + maven { + name = "GXFGithubPackages" + url = uri("https://maven.pkg.github.com/osgp/*") + credentials { + username = project.findProperty("github.username") as String? ?: System.getenv("GITHUB_ACTOR") + password = project.findProperty("github.token") as String? ?: System.getenv("GITHUB_TOKEN") + } + } + } + + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + + extensions.configure { + imports { + mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) + } + } + + tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "17" + } + } + + tasks.withType { + useJUnitPlatform() + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..3c98f55 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,24 @@ +# Copyright 2023 Alliander N.V. +version: '2' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.3.0 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "22181:2181" + kafka: + image: confluentinc/cp-kafka:7.3.0 + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_CREATE_TOPICS: "avroTopic:1:1" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e411586 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7b2730b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +// Copyright 2023 Alliander N.V. + +rootProject.name = "gxf-soap-bridge" + +include("application")