From f5b45bf1aed65c9f2e95b8869ccaece81481186d Mon Sep 17 00:00:00 2001 From: Sander Verbruggen Date: Tue, 14 Nov 2023 13:25:47 +0100 Subject: [PATCH] Initial commit --- .github/workflows/gradle.yml | 37 ++ .gitignore | 37 ++ LICENSES/Apache-2.0.txt | 73 +++ application/build.gradle.kts | 56 +++ .../kotlin/org/gxf/soapbridge/EndToEndTest.kt | 100 ++++ .../soapbridge/SoapBridgeApplicationTests.kt | 17 + .../resources/application.yaml | 65 +++ .../resources/generate_certs.sh | 47 ++ .../integrationTest/resources/localhost.ext | 5 + .../resources/organisations/testClient.pfx | Bin 0 -> 4344 bytes .../resources/proxy.keystore.jks | Bin 0 -> 4494 bytes .../resources/proxy.truststore.jks | Bin 0 -> 1830 bytes .../integrationTest/resources/sign-key.der | Bin 0 -> 1216 bytes .../integrationTest/resources/verify-key.der | Bin 0 -> 294 bytes .../gxf/soapbridge/SoapBridgeApplication.kt | 17 + .../configuration/SecurityConfiguration.kt | 41 ++ .../ObservabilityConfiguration.kt | 18 + .../src/main/resources/application-dev.yml | 10 + .../src/main/resources/application.yaml | 45 ++ .../src/main/resources/proxy-server.yml | 140 ++++++ build.gradle.kts | 64 +++ components/core/build.gradle.kts | 9 + .../messaging/ProxyRequestsMessageSender.kt | 9 + .../messaging/ProxyResponsesMessageSender.kt | 9 + .../exceptions/ProxyMessageException.kt | 10 + .../messages/ProxyServerBaseMessage.kt | 25 + .../messages/ProxyServerRequestMessage.kt | 83 ++++ .../messages/ProxyServerResponseMessage.kt | 50 ++ .../services/ProxyRequestHandler.kt | 9 + .../services/ProxyResponseHandler.kt | 9 + components/kafka/build.gradle.kts | 17 + .../listeners/ProxyRequestKafkaListener.kt | 32 ++ .../listeners/ProxyResponseKafkaListener.kt | 34 ++ .../TopicsConfigurationProperties.kt | 16 + .../kafka/senders/ProxyRequestKafkaSender.kt | 25 + .../kafka/senders/ProxyResponseKafkaSender.kt | 25 + components/soap/build.gradle.kts | 34 ++ .../configuration/SoapConfiguration.java | 22 + .../factories/HostnameVerifierFactory.java | 33 ++ .../factories/HttpsUrlConnectionFactory.java | 106 +++++ .../factories/SslContextFactory.java | 152 ++++++ .../services/ClientCommunicationService.java | 67 +++ .../services/ConnectionCacheService.java | 95 ++++ .../PlatformCommunicationService.java | 66 +++ .../application/services/SigningService.java | 86 ++++ .../services/SslContextCacheService.java | 79 ++++ .../utils/RandomStringFactory.java | 52 +++ .../soapbridge/soap/clients/Connection.java | 72 +++ .../soapbridge/soap/clients/SoapClient.java | 173 +++++++ .../soap/endpoints/SoapEndpoint.java | 440 ++++++++++++++++++ .../ConnectionNotFoundInCacheException.java | 14 + .../soap/exceptions/ProxyServerException.java | 25 + ...leToCreateHttpsURLConnectionException.java | 14 + .../UnableToCreateKeyManagersException.java | 14 + .../UnableToCreateTrustManagersException.java | 14 + .../soap/valueobjects/ClientCertificate.java | 24 + .../SecurityConfigurationProperties.kt | 77 +++ .../properties/SoapConfigurationProperties.kt | 34 ++ .../utils/RandomStringServiceTests.java | 32 ++ .../soap/clients/SoapClientTest.java | 106 +++++ docker-compose.yaml | 35 ++ gradle.properties | 2 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 240 ++++++++++ gradlew.bat | 91 ++++ settings.gradle.kts | 8 + 67 files changed, 3346 insertions(+) create mode 100644 .github/workflows/gradle.yml create mode 100644 .gitignore create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 application/build.gradle.kts create mode 100644 application/src/integrationTest/kotlin/org/gxf/soapbridge/EndToEndTest.kt create mode 100644 application/src/integrationTest/kotlin/org/gxf/soapbridge/SoapBridgeApplicationTests.kt create mode 100644 application/src/integrationTest/resources/application.yaml create mode 100644 application/src/integrationTest/resources/generate_certs.sh create mode 100644 application/src/integrationTest/resources/localhost.ext create mode 100644 application/src/integrationTest/resources/organisations/testClient.pfx create mode 100644 application/src/integrationTest/resources/proxy.keystore.jks create mode 100644 application/src/integrationTest/resources/proxy.truststore.jks create mode 100644 application/src/integrationTest/resources/sign-key.der create mode 100644 application/src/integrationTest/resources/verify-key.der create mode 100644 application/src/main/kotlin/org/gxf/soapbridge/SoapBridgeApplication.kt create mode 100644 application/src/main/kotlin/org/gxf/soapbridge/configuration/SecurityConfiguration.kt create mode 100644 application/src/main/kotlin/org/gxf/soapbridge/observability/ObservabilityConfiguration.kt create mode 100644 application/src/main/resources/application-dev.yml create mode 100644 application/src/main/resources/application.yaml create mode 100644 application/src/main/resources/proxy-server.yml create mode 100644 build.gradle.kts create mode 100644 components/core/build.gradle.kts create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyRequestsMessageSender.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyResponsesMessageSender.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/messaging/exceptions/ProxyMessageException.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerBaseMessage.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerRequestMessage.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerResponseMessage.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyRequestHandler.kt create mode 100644 components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyResponseHandler.kt create mode 100644 components/kafka/build.gradle.kts create mode 100644 components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt create mode 100644 components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt create mode 100644 components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt create mode 100644 components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt create mode 100644 components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt create mode 100644 components/soap/build.gradle.kts create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/configuration/SoapConfiguration.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/services/SigningService.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/application/utils/RandomStringFactory.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java create mode 100644 components/soap/src/main/java/org/gxf/soapbridge/soap/valueobjects/ClientCertificate.java create mode 100644 components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SecurityConfigurationProperties.kt create mode 100644 components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SoapConfigurationProperties.kt create mode 100644 components/soap/src/test/java/org/gxf/soapbridge/application/utils/RandomStringServiceTests.java create mode 100644 components/soap/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java create mode 100644 docker-compose.yaml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..403c8df --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,37 @@ +# Copyright 2023 Alliander N.V. + +name: Gradle Pipeline + +on: + push: + branches: [ "main" , "develop" ] + tags: [ "v**" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + build: + 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: Build with Gradle + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: build integrationTest bootBuildImage sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + - name: Publish Docker image + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref_type == 'tag' + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: 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..453f647 --- /dev/null +++ b/application/build.gradle.kts @@ -0,0 +1,56 @@ +// Copyright 2023 Alliander N.V. + +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(kotlin("reflect")) + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") + + implementation(project(":components:kafka")) + implementation(project(":components:soap")) + + implementation("org.springframework:spring-aspects") + + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") +} + +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(project(":components:soap")) + + 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..ce539e9 --- /dev/null +++ b/application/src/integrationTest/kotlin/org/gxf/soapbridge/EndToEndTest.kt @@ -0,0 +1,100 @@ +// Copyright 2023 Alliander N.V. + +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.gxf.soapbridge.application.properties.SoapConfigurationProperties +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 + + +@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, + @Autowired private val soapConfigProperties: SoapConfigurationProperties +) { + 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(applicationContext: ApplicationContext) { + // Setup 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) } + .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..3566f8c --- /dev/null +++ b/application/src/integrationTest/kotlin/org/gxf/soapbridge/SoapBridgeApplicationTests.kt @@ -0,0 +1,17 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge + +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() { + } + +} diff --git a/application/src/integrationTest/resources/application.yaml b/application/src/integrationTest/resources/application.yaml new file mode 100644 index 0000000..8c4b888 --- /dev/null +++ b/application/src/integrationTest/resources/application.yaml @@ -0,0 +1,65 @@ +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 + +wiremock: + call-proxy: + url: "https://localhost:9000" + notification-proxy: + url: "https://localhost:9001" + +proxy-server: + network-zone: BOTH + +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 + +topics: +# short circuit configuration: topics are linked back to the same proxy instance + outgoing: + requests: requests + responses: responses + incoming: + requests: requests + responses: responses + +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 100644 index 0000000..33e99f8 --- /dev/null +++ b/application/src/integrationTest/resources/generate_certs.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Copyright 2023 Alliander N.V. + +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 0000000000000000000000000000000000000000..b880a09abec0aded0a6f855f647123f95f7551a8 GIT binary patch literal 4344 zcmai&WmFT6*T*-?k-})CWOR=1mXa8Z4FORJ>F!q8KtMuL8bL-5lo%zTlmgO?(jg_I zQxG5j=XuWW_q_YRxb?mFocr=~&xN9>PY8g7P!x3~38~-<-52MSz+1pV6m>2UiaPVJ zoCZaa*#6rhDMXQ&{*_<^K)~Oi`)>ybeeh2~ObNXY75KNLfU**Sm`rPMMrDwE0s;^K zN!~iH8390o9U7$T75m1nbTDA&fG2bZv2T@TFX2&FE9DyQUeqaPIS~L)N zZ7JtOKbUNvep$nULZ^NEoKR-QB_nUqTg`aO#ltoTkw@N;Z~ zQYpK4De{smz@EYo&ao5uuPkFbQo19#9fbu>2`xvi6KbBS{6yPk$r zN*MSGl}eAH+}umzYbzDYEATY#Oz~8_j;qbwqqgjVji}-Ui~Vk;YsXoOvMwGbs|xuQmvmNVduW9a5=%W=q6ZJVWJ6=!RZ$5Ylp| zrYr5Od=`{`qau+xB9AndoOe6!gBic<)pQG7Dk8L60ncypLT%-N%Q?R5B*(i0D>aEG zJ36=nNNJDtc-iT|-KAk1y>an{ptFy6$^mXfYCEKW4D+t!aVt=kDy{`xwAE}>*e)Nz z2|>03sft(fig#m0%LfxJ(|WtHr@7B2pwHm3b)?;>gUb1KT$V*RvQRJLu2ft@aAO{i zJ}oWv{+%@)!{wTR_^sFhM>WO~T(z%wew@)6{$q3_@}T^u_wT*40qMlVXnJh18fX)}=@K_tbF z_Y{g49yoB8Uyt=1PPWmyA%5+Rv)l0k#SQJ^FxF3PL*UH4&i3p_EI+GPo1>^b6RCcH#Q9XZ{pq1YDqFei)3gG08pd{*;; z%sxU~(US1*TrVWUNB@W&13%yEY7d8OKD$*k+-9wrZB^-Oc+MnJ2>TJ8RF3(&scSWD zUc3JkJ^diZrP;0c4LMD_b|4o-cN1KNkVSki-fEh7lnbWW^O?HFvqTK(s}atf5|!y- z-G^qS)rH-!kPG>Yfzxa1;Q~R4YzOfE>s&D*VQcVQIsPlqV$Dr3x#gZL?0u;kVM*Jc zsa#@FYgKvf`|ZwZaf*mUUiQ(9Mka=(P*6Yq*`O6q<0Mb4!F={ZRpzOj1_EfY?AnB#50?pVU~s2W6WFmpSbfD)<$~Cmz-ctCVaTnne8( zjOX=j2&!6zfWlt}KhJhLb19jc{r1YQq+bpkrE??R?X|gx&V1rumhEM#M{?`i6h|M! z27l_XyDlZNSzk9w!1ze`YH0+ZtA4KbKH>|3J04$P@gX6Q&M9xjcJ9$$5?xr@gU0f% zAOi=Z#&MDwPv zr;MBhtgHW!-{eh(SKt?}#g*-T9~&Zv4q~RG+mZ$w>q35YB4M4NP~pnG_a#|n$or4Q zRhXyv6$@2oRR=?yzfORe-92uTgJLx`p}(VMHGSy?&6xG3_TEPJl@4Dk)V1`KP*|!n zgj1Knk#ps>Wj--G&3BPCs|k4|lucx#lM4-J4W8>huGgJCkuKg}WwvS`T@2tm+Bj$vC4gT5w za2cmaft5rHkoUFtZEBDRM%&Y_=l7Pp7HJy}LDZYDn@3J+#x6opWdHKwKL8?QAwrQc z{FT9fjf#Z)|JX!91|%p%k=8;{q}BfoIKR6O^|exf3I7#17>SVvUE0Xr4=MMzLjv5w zh)|@)SiSwn@ZcKXLWhrC{&Ii1jr(sTF-B8-hK)X%RkEM@SVTA`5_c{({lFmU5v!FVwK%Vc>qKx_#C9|*HZCdk6suOX z8^B?pj_}e0%kAGsL}aw)wB_V|yvE5`hi9cOwO1wEwhyKhpYkXq{A%WJAn27eIDrIW z2Oi2%QSIHae}TJGY%-0pj>&1nD$t>|jtpPNjvgBDXe$y{>x5XQj?*`-o%9yNyhf~! z z?4x5UxqFYU+M76~l!9_`wW%f94g>n}?s_Lk-wAZEC0>E>n&~Pf8yU{7U0do8x=I}i zu4ER&H))wQESn?+@DOY4GHVVfDeULy2qo=}h&2@wNz}^ zuLBzIEIvU73_;b3bh=l-RuprOS;Zaat8BVbh?I+Z5&_#=Jpm%WZs~oq2z$k8J1ypQ z&rZKLDINIqAy-^I5@T<^A{Wgn0;Bz=XA#oWR^!7{$w>0THQGKnPZ$+e0A>stA%9cO?Fd8ML7(b;&s&UcHquUdd!r zQOVsT`b67ON=PW42BhxQ0g{JoJYR3*+EFXKRTqj;p+ECiz;3BK@CLKV!nGkmei_ms zk=w8o>B#0gqBjsu7~gHyvaZf)5jvgMb*dEt@5JM1_QjX%ZZyiJsq|l4y*ybGFXUMc z&FvGnn-UG=9N_KX?gsOnicE9_&+P-U=FZ|}41V*}AfKt+aD1x*fZ|0lLmLinTL;_h zkm6!I+`UhU#AGnP6DdY*`z9CEN@OR3J)DB$Jz7t>eN|%P1RQ;2qb-}(BOZ=uLrMOGKu9x~V8r^bolfA-|;@ zznMw62$i^my*Hn+lOx|6&98Y{H0%!+3VUU(+rd-533lsL^NJ+CKGG&fg>LVrzuM3q zZhy@rw|M$-yw^EM^J7b=$a5nI@*w#BSozOkX1sf_(nX=Y$rH!(x-=D#CtF5PDK>hv zJdOKtONrQwC-fHK5T9>tVh{qIn{*&!K9Kf>DJ$^GgTw6HPg9a{McJ0EG?XN<_QciE zQhs-6ykTDo>@%gJ2_qJ-=hw;F-=uvQ?;RBIHOLKQcxhz@F7=)S{&)yF1Mf|tV2K{;)9H=eBuaLiC21Nqrs;4H)Mm`v?Rmb(|9Ed-0g@P zOu^OWR#Co98?9(@_38Zeea!227u6R&L}BSXJ?32FJ6Dw2pw{r?TZ&n@W*MKEv;H2a z_8b_+M;5lkZQ)8oGS6S>hM7&-9yA&JZYdGoF-5!CsPVFE*BM1ORQL#aIu$?2&nk!X z$^XcDJFkd3S>Vp-yC;>_#I6&zaKrOtX{n)$Ki}Dv9eFJpou}{}txrPBRZM2|l~}k8 zG4~o9lLkFhmM>E4J@C@6txUKKv=C@)f?&VubxfY68DW$AS1p)~av=!0GHgz{VEfEe z#$!{c(4wOBEJ7}n+m9@NmXB6}hu6>lrq@WQd_#>s6eh0vDVT+E&c{2iQWHCU7BFdW ze%^i{cA(bI@8N(ZP|WC?+$AhohxSUa-tY%;YcHr9e;smiqKr>i4T|GXMPR(HDu`Ai zlV*2EPV$yY6l1CDZh~cX<&~b7(g+W{n+(tV8AKY}*r9Y@$BiF>Fyc4H&OvJxwmzh$ z$|jZlt3%nBS0+vL)1K4C)0ronoZQkYj{n_6%msx%)fsuFS2v6&*iicY)tfO4N!q{$ZaGO9`r{3jLuK zHK4aPE9KN092$=QnRFT4#CH`!&6(n8uo$-aWy<7_5Thh!MIycdQU; zlYYo<>o#v+M*UtgY2SD6KTMGRjFJ@lp9Wt z3BPg)Qco67%2n_(ciGBM#~nP9or?JRHeT!jKw-2bNmV4`ayB6>^lp~|0TZ?bw=X&G z5Ym3jKy{m^Ui!7Q(*mBg3~NdRueXQE)@NwmFSXvDtxfi9C(NuqEAAZ6f2+FXaG&Zo z*((EtQZdg%#?s(cV3+Y zDbbxj+%Ur>gPUYHs1SKD>84t5LmW*Q) zQp}7`D+2zk59LZH*Ps7;b-iy(Yj7;QTlD0Qmj?A$oBJBD$JyH==xdaA5}BZBxY}3%O9w zmw`}qs5q4JpPvB%kQhM331QC$u)g451fg#(-|~?!Ts7>3`Kbmp{Js~ECsB#P5rLL8 OQ?@m6$`=2*2mc4%ivhv_ literal 0 HcmV?d00001 diff --git a/application/src/integrationTest/resources/proxy.keystore.jks b/application/src/integrationTest/resources/proxy.keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..eae9f2cf12f5e173c750bbd551220a5f9e20ce0f GIT binary patch literal 4494 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 literal 0 HcmV?d00001 diff --git a/application/src/integrationTest/resources/proxy.truststore.jks b/application/src/integrationTest/resources/proxy.truststore.jks new file mode 100644 index 0000000000000000000000000000000000000000..d7da0d3a91de9afbddc084a01b98ac299524f473 GIT binary patch literal 1830 zcmV+>2if>Af(If30Ru3C2FwNtDuzgg_YDCD0ic2gy##^=xiEqTwJ?GPu?7h$hDe6@ z4FLxRpn?XXFoFi20s#Opf(Dlc2`Yw2hW8Bt2LUi<1_>&LNQU+thDZTr0|Wso1Q7juY#N)WPZ;3V4_{)6B)))x1|ai;hx~25mZb6hVc*Rg)GyR= z@@y%>S1YPlqibXiEHFSDQcf}>4!rnXk@2!xevsyPUQ_}6FJAhy!<7?D1WBXznm&@Owp~1802{HFf^zih z(KP;SfWi(#6-mfC;-OG))0f$??S#dkKZ>SSZt4$~%iZ0=)L(hRMLCDcbzYr-!cvX( za65s2U~BrGex)22ut%<*B3UT^)-UqzoNy8}ag?SQ;C-z?xDbh$!9g(4?R((Eaa%kR zVuI3V5dQHbW^(KzSMrNtxYPX*p(U4n;5Sh$aw_u!cFQV~MreJre2GK^#%-^*0BzsI z>Oa6$&`2+4E@{#So-tJN0UkH?3CI|H2e_�XYxMsFI{ly2TBOgRNb5U+?M-L!VkF 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 eGC0XRSjYUS0K5f)$_b+G3Yyo literal 0 HcmV?d00001 diff --git a/application/src/integrationTest/resources/verify-key.der b/application/src/integrationTest/resources/verify-key.der new file mode 100644 index 0000000000000000000000000000000000000000..7bda5f95e15846014bd7ca970946681f298301c1 GIT binary patch literal 294 zcmV+>0ondAf&n5h4F(A+hDe6@4FLfG1potr0S^E$f&mHwf&l>lssizsUy{>70jb1P~iv6q&|H`YMxW zA}zCglDtsv5EFthg)m+1+sg}1J2>36RwyDNIJi+En`Qw)XlOOc9xWub-YOKzQ$u47D{pw3IA17UzupSoIngVRQbd=p_ s0UVqBHwF?U!VOU~AHE>V&%+CxFu@Nnb^4!r)edd_a3ZUd0s{d60ko@u8UO$Q literal 0 HcmV?d00001 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..2706b03 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/SoapBridgeApplication.kt @@ -0,0 +1,17 @@ +// Copyright 2023 Alliander N.V. + +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..03a4c53 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/configuration/SecurityConfiguration.kt @@ -0,0 +1,41 @@ +// Copyright 2023 Alliander N.V. + +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 + @Throws(Exception::class) + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http.authorizeHttpRequests { + it + .anyRequest().authenticated() + } + http.x509 { + it + .subjectPrincipalRegex("CN=(.*?)(?:,|$)") + .userDetailsService(userDetailsService()) + } + http.csrf { it.disable() } + return http.build() + } + + /** + * Uses the CN of the client certificate as the username for Springs Principal object + */ + @Bean + fun userDetailsService(): UserDetailsService { + return UserDetailsService { username -> + return@UserDetailsService User( + username, "", emptyList() + ) + } + } +} diff --git a/application/src/main/kotlin/org/gxf/soapbridge/observability/ObservabilityConfiguration.kt b/application/src/main/kotlin/org/gxf/soapbridge/observability/ObservabilityConfiguration.kt new file mode 100644 index 0000000..67806a2 --- /dev/null +++ b/application/src/main/kotlin/org/gxf/soapbridge/observability/ObservabilityConfiguration.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.observability + +import io.micrometer.observation.ObservationRegistry +import io.micrometer.observation.aop.ObservedAspect +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration(proxyBeanMethods = false) +internal class ObservabilityConfiguration { + + @Bean + fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect { + return ObservedAspect(observationRegistry) + } +} diff --git a/application/src/main/resources/application-dev.yml b/application/src/main/resources/application-dev.yml new file mode 100644 index 0000000..98d3b87 --- /dev/null +++ b/application/src/main/resources/application-dev.yml @@ -0,0 +1,10 @@ +logging: + level: + org: + gxf: + soapbridge: DEBUG +spring: + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: gxf-proxy diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml new file mode 100644 index 0000000..0ddc75a --- /dev/null +++ b/application/src/main/resources/application.yaml @@ -0,0 +1,45 @@ +management: + endpoints: + web: + exposure: + include: prometheus + +proxy-server: + network-zone: IT + +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 + +topics: + calls: + requests: proxy-server-calls-requests + responses: proxy-server-calls-responses + notifications: + requests: proxy-server-notification-requests + responses: proxy-server-notification-responses + +soap: + notification: + host: localhost + port: 443 + protocol: https + platform: + host: localhost + port: 443 + protocol: https + hostname-verification-strategy: BROWSER_COMPATIBLE_HOSTNAMES + time-out: 45 + custom-timeouts: SetScheduleRequest,180,GetStatusRequest,120 diff --git a/application/src/main/resources/proxy-server.yml b/application/src/main/resources/proxy-server.yml new file mode 100644 index 0000000..1533737 --- /dev/null +++ b/application/src/main/resources/proxy-server.yml @@ -0,0 +1,140 @@ +jms: + proxy: + server: + back: + 'off': + multiplier: 2 + initial: + redelivery: + delay: 60000 + maximum: + redeliveries: 3 + redelivery: + delay: 300000 + notification: + back: + 'off': + multiplier: 2 + initial: + redelivery: + delay: 60000 + maximum: + redeliveries: 3 + redelivery: + delay: 300000 + redelivery: + delay: 60000 + requests: + concurrent: + consumers: 10 + delivery: + persistent: false + explicit: + qos: + enabled: true + max: + concurrent: + consumers: 10 + receive: + timeout: 10 + time: + to: + live: 3600000 + responses: + concurrent: + consumers: 10 + delivery: + persistent: false + explicit: + qos: + enabled: true + max: + concurrent: + consumers: 10 + receive: + timeout: 10 + time: + to: + live: 3600000 + use: + exponential: + back: + 'off': true + redelivery: + delay: 60000 + requests: + concurrent: + consumers: 10 + delivery: + persistent: false + explicit: + qos: + enabled: true + max: + concurrent: + consumers: 10 + receive: + timeout: 10 + time: + to: + live: 3600000 + responses: + concurrent: + consumers: 10 + delivery: + persistent: false + explicit: + qos: + enabled: true + max: + concurrent: + consumers: 10 + receive: + timeout: 10 + time: + to: + live: 3600000 + use: + exponential: + back: + 'off': true + +topics: + calls: + requests: proxy-server-webapp-requests + responses: proxy-server-webapp-responses + notification: + requests: proxy-server-notification-requests + responses: proxy-server-notification-responses + +proxy-server: + custom-timeouts: SetScheduleRequest,180,GetStatusRequest,120 + network-zone: BOTH + notification: + host: localhost + port: 443 + protocol: https + platform: + host: localhost + port: 443 + protocol: https + security: + key-store: + location: /etc/ssl/certs + password: 1234 + type: pkcs12 + trust-store: + location: /etc/ssl/certs/trust.jks + password: 123456 + signing: + key-type: RSA + sign-key: /etc/ssl/certs/proxy-server/sign-key.der + verify-key: /etc/ssl/certs/proxy-server/verify-key.der + provider: SunRsaSign + signature: SHA256withRSA + time-out: 45 +soap: + client: + hostname: + verification: + strategy: BROWSER_COMPATIBLE_HOSTNAMES diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..517ea15 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,64 @@ +// 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.4" 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", "SSS-gxf-soap-bridge") + property("sonar.organization", "digitalisering") + } +} +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") + + group = "org.gxf.soap-bridge" + version = rootProject.version + + repositories { + mavenCentral() + } + + 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/components/core/build.gradle.kts b/components/core/build.gradle.kts new file mode 100644 index 0000000..ce4a92e --- /dev/null +++ b/components/core/build.gradle.kts @@ -0,0 +1,9 @@ +// Copyright 2023 Alliander N.V. + +dependencies { + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") + + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + testImplementation("org.junit.jupiter:junit-jupiter-params") +} diff --git a/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyRequestsMessageSender.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyRequestsMessageSender.kt new file mode 100644 index 0000000..f1ad634 --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyRequestsMessageSender.kt @@ -0,0 +1,9 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.messaging + +import org.gxf.soapbridge.messaging.messages.ProxyServerRequestMessage + +interface ProxyRequestsMessageSender { + fun send(requestMessage: ProxyServerRequestMessage) +} diff --git a/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyResponsesMessageSender.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyResponsesMessageSender.kt new file mode 100644 index 0000000..cdc04e7 --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/ProxyResponsesMessageSender.kt @@ -0,0 +1,9 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.messaging + +import org.gxf.soapbridge.messaging.messages.ProxyServerResponseMessage + +interface ProxyResponsesMessageSender { + fun send(responseMessage: ProxyServerResponseMessage) +} diff --git a/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/exceptions/ProxyMessageException.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/exceptions/ProxyMessageException.kt new file mode 100644 index 0000000..71f7471 --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/exceptions/ProxyMessageException.kt @@ -0,0 +1,10 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.messaging.exceptions + + +class ProxyMessageException : Exception { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, t: Throwable?) : super(message, t) +} diff --git a/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerBaseMessage.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerBaseMessage.kt new file mode 100644 index 0000000..1a9a832 --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerBaseMessage.kt @@ -0,0 +1,25 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.messaging.messages + +import java.util.* + + +/** 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) + + /** 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/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerRequestMessage.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerRequestMessage.kt new file mode 100644 index 0000000..4aa1c9a --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerRequestMessage.kt @@ -0,0 +1,83 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.messaging.messages + +import mu.KotlinLogging +import org.gxf.soapbridge.messaging.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 ProxyServerException + */ + @Throws(ProxyMessageException::class) + fun createInstanceFromString(string: String): ProxyServerRequestMessage { + val split = string.split(SEPARATOR) + "een of andere string".lines() + 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/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerResponseMessage.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerResponseMessage.kt new file mode 100644 index 0000000..234444a --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/messaging/messages/ProxyServerResponseMessage.kt @@ -0,0 +1,50 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.messaging.messages + +import mu.KotlinLogging +import org.gxf.soapbridge.messaging.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 ProxyServerException + */ + @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/components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyRequestHandler.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyRequestHandler.kt new file mode 100644 index 0000000..d22f5f7 --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyRequestHandler.kt @@ -0,0 +1,9 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.services + +import org.gxf.soapbridge.messaging.messages.ProxyServerRequestMessage + +interface ProxyRequestHandler { + fun handleIncomingRequest(proxyServerRequestMessage: ProxyServerRequestMessage) +} diff --git a/components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyResponseHandler.kt b/components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyResponseHandler.kt new file mode 100644 index 0000000..f14b0cf --- /dev/null +++ b/components/core/src/main/kotlin/org/gxf/soapbridge/services/ProxyResponseHandler.kt @@ -0,0 +1,9 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.services + +import org.gxf.soapbridge.messaging.messages.ProxyServerResponseMessage + +interface ProxyResponseHandler { + fun handleIncomingResponse(proxyServerResponseMessage: ProxyServerResponseMessage) +} diff --git a/components/kafka/build.gradle.kts b/components/kafka/build.gradle.kts new file mode 100644 index 0000000..485cce7 --- /dev/null +++ b/components/kafka/build.gradle.kts @@ -0,0 +1,17 @@ +// Copyright 2023 Alliander N.V. + +dependencies { + implementation("org.springframework.boot:spring-boot-autoconfigure") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework:spring-aop") + implementation("org.springframework.kafka:spring-kafka") + implementation("com.microsoft.azure:msal4j:1.13.10") + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation(project(":components:core")) + + testImplementation("org.springframework:spring-test") + + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + testImplementation("org.junit.jupiter:junit-jupiter-params") +} diff --git a/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt new file mode 100644 index 0000000..f32d77e --- /dev/null +++ b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyRequestKafkaListener.kt @@ -0,0 +1,32 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.kafka.listeners + +import io.micrometer.observation.annotation.Observed +import mu.KotlinLogging +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.gxf.soapbridge.messaging.messages.ProxyServerRequestMessage +import org.gxf.soapbridge.services.ProxyRequestHandler +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.annotation.RetryableTopic +import org.springframework.retry.annotation.Backoff +import org.springframework.stereotype.Component +import java.net.SocketTimeoutException + +@Component +class ProxyRequestKafkaListener(private val proxyRequestHandler: ProxyRequestHandler) { + private val logger = KotlinLogging.logger { } + + @Observed(name = "requests.consumed") + @KafkaListener(topics = ["\${topics.incoming.requests}"], id = "gxf-request-consumer") + @RetryableTopic( + backoff = Backoff(value = 3000L), + attempts = "2", + include = [SocketTimeoutException::class] + ) + fun consume(record: ConsumerRecord) { + logger.info("Received message") + val requestMessage = ProxyServerRequestMessage.createInstanceFromString(record.value()) + proxyRequestHandler.handleIncomingRequest(requestMessage) + } +} diff --git a/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt new file mode 100644 index 0000000..115a396 --- /dev/null +++ b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/listeners/ProxyResponseKafkaListener.kt @@ -0,0 +1,34 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.kafka.listeners + +import io.micrometer.observation.annotation.Observed +import mu.KotlinLogging +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.gxf.soapbridge.messaging.messages.ProxyServerResponseMessage +import org.gxf.soapbridge.services.ProxyResponseHandler +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.annotation.RetryableTopic +import org.springframework.retry.annotation.Backoff +import org.springframework.stereotype.Component +import java.net.SocketTimeoutException + +@Component +class ProxyResponseKafkaListener( + private val proxyResponseHandler: ProxyResponseHandler +) { + private val logger = KotlinLogging.logger { } + + @Observed(name = "responses.consumed") + @KafkaListener(topics = ["\${topics.incoming.responses}"], id = "gxf-response-consumer") + @RetryableTopic( + backoff = Backoff(value = 3000L), + attempts = "2", + include = [SocketTimeoutException::class] + ) + fun consume(record: ConsumerRecord) { + logger.info("Received response") + val responseMessage = ProxyServerResponseMessage.createInstanceFromString(record.value()) + proxyResponseHandler.handleIncomingResponse(responseMessage) + } +} diff --git a/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt new file mode 100644 index 0000000..6d3eb80 --- /dev/null +++ b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/properties/TopicsConfigurationProperties.kt @@ -0,0 +1,16 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.kafka.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("topics") +class TopicsConfigurationProperties( + val outgoing: RequestResponseTopics, + val incoming: RequestResponseTopics +) + +class RequestResponseTopics( + val requests: String, + val responses: String +) diff --git a/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt new file mode 100644 index 0000000..cbe45f4 --- /dev/null +++ b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyRequestKafkaSender.kt @@ -0,0 +1,25 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.kafka.senders + +import mu.KotlinLogging +import org.gxf.soapbridge.kafka.properties.TopicsConfigurationProperties +import org.gxf.soapbridge.messaging.ProxyRequestsMessageSender +import org.gxf.soapbridge.messaging.messages.ProxyServerRequestMessage +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component + +@Component +class ProxyRequestKafkaSender( + private val kafkaTemplate: KafkaTemplate, + topicConfiguration: TopicsConfigurationProperties +) : ProxyRequestsMessageSender { + private val logger = KotlinLogging.logger {} + + private val topic = topicConfiguration.outgoing.requests + + override fun send(requestMessage: ProxyServerRequestMessage) { + logger.debug("SOAP payload: ${requestMessage.soapPayload} to $topic") + kafkaTemplate.send(topic, requestMessage.constructSignedString()) + } +} diff --git a/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt new file mode 100644 index 0000000..1304501 --- /dev/null +++ b/components/kafka/src/main/kotlin/org/gxf/soapbridge/kafka/senders/ProxyResponseKafkaSender.kt @@ -0,0 +1,25 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.kafka.senders + +import mu.KotlinLogging +import org.gxf.soapbridge.kafka.properties.TopicsConfigurationProperties +import org.gxf.soapbridge.messaging.ProxyResponsesMessageSender +import org.gxf.soapbridge.messaging.messages.ProxyServerResponseMessage +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component + +@Component +class ProxyResponseKafkaSender( + private val kafkaTemplate: KafkaTemplate, + topicConfiguration: TopicsConfigurationProperties +) : ProxyResponsesMessageSender { + private val logger = KotlinLogging.logger {} + + private val topic = topicConfiguration.outgoing.responses + + override fun send(responseMessage: ProxyServerResponseMessage) { + logger.debug("SOAP payload: ${responseMessage.soapResponse} to $topic") + kafkaTemplate.send(topic, responseMessage.constructSignedString()) + } +} diff --git a/components/soap/build.gradle.kts b/components/soap/build.gradle.kts new file mode 100644 index 0000000..f52db11 --- /dev/null +++ b/components/soap/build.gradle.kts @@ -0,0 +1,34 @@ +// Copyright 2023 Alliander N.V. + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation(kotlin("reflect")) + implementation("org.apache.httpcomponents:httpclient:4.5.13") + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation(project(":components:core")) + + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + testImplementation("org.springframework:spring-test") + testImplementation("org.mockito:mockito-junit-jupiter") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + testImplementation("org.junit.jupiter:junit-jupiter-params") +} + +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.testcontainers:kafka:1.17.6") + } + } + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/application/configuration/SoapConfiguration.java b/components/soap/src/main/java/org/gxf/soapbridge/application/configuration/SoapConfiguration.java new file mode 100644 index 0000000..6c3a65f --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/configuration/SoapConfiguration.java @@ -0,0 +1,22 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.application.configuration; + +import jakarta.servlet.http.HttpServletRequest; +import org.gxf.soapbridge.soap.endpoints.SoapEndpoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.handler.AbstractHandlerMapping; + +@Component +public class SoapConfiguration extends AbstractHandlerMapping { + private final SoapEndpoint soapEndpoint; + + public SoapConfiguration(final SoapEndpoint soapEndpoint) { + this.soapEndpoint = soapEndpoint; + } + + @Override + protected Object getHandlerInternal(final HttpServletRequest request) { + return soapEndpoint; + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java b/components/soap/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java new file mode 100644 index 0000000..9182d33 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/factories/HostnameVerifierFactory.java @@ -0,0 +1,33 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.application.factories; + +import org.apache.http.conn.ssl.DefaultHostnameVerifier; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.gxf.soapbridge.application.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.springframework.stereotype.Component; + +import javax.net.ssl.HostnameVerifier; + +import static org.gxf.soapbridge.application.properties.HostnameVerificationStrategy.ALLOW_ALL_HOSTNAMES; +import static org.gxf.soapbridge.application.properties.HostnameVerificationStrategy.BROWSER_COMPATIBLE_HOSTNAMES; + +@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/components/soap/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java b/components/soap/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java new file mode 100644 index 0000000..04486ba --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/factories/HttpsUrlConnectionFactory.java @@ -0,0 +1,106 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.factories; + +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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * 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. + */ + @Autowired + private SslContextCacheService sslContextCacheService; + + @Autowired + private 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. + SSLContext sslContext = null; + if (StringUtils.isEmpty(commonName)) { + sslContext = sslContextCacheService.getSslContext(); + } else { + sslContext = sslContextCacheService.getSslContextForCommonName(commonName); + } + // 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. + HttpsURLConnection.setDefaultHostnameVerifier( + hostnameVerifierFactory.getHostnameVerifier()); + final HttpsURLConnection connection = (HttpsURLConnection) new URL(uri).openConnection(); + connection.setSSLSocketFactory(sslContext.getSocketFactory()); + connection.setDoInput(true); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + connection.setRequestProperty( + "Accept-Encoding", "text/xml;charset=" + StandardCharsets.UTF_8.name()); + connection.setRequestProperty("Accept-Charset", StandardCharsets.UTF_8.name()); + connection.setRequestProperty( + "Content-Type", "text/xml;charset=" + StandardCharsets.UTF_8.name()); + connection.setRequestProperty("SOAP-ACTION", ""); + connection.setRequestProperty("Content-Length", contentLength); + connection.setRequestProperty("Host", host); + connection.setRequestProperty("Connection", "Keep-Alive"); + connection.setRequestProperty( + "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) { + LOGGER.error("Creating connection failed.", e); + throw new UnableToCreateHttpsURLConnectionException(e.getMessage()); + } + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java b/components/soap/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java new file mode 100644 index 0000000..828754f --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/factories/SslContextFactory.java @@ -0,0 +1,152 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.factories; + +import org.gxf.soapbridge.application.properties.SecurityConfigurationProperties; +import org.gxf.soapbridge.application.properties.StoreConfigurationProperties; +import org.gxf.soapbridge.application.services.SslContextCacheService; +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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.net.ssl.*; +import java.io.FileInputStream; +import java.io.InputStream; +import java.security.KeyStore; + +/** + * 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"; + + @Autowired + private 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; + + /** + * 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.info("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.info("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. + 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/components/soap/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java b/components/soap/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java new file mode 100644 index 0000000..4816892 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/services/ClientCommunicationService.java @@ -0,0 +1,67 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.messaging.messages.ProxyServerResponseMessage; +import org.gxf.soapbridge.services.ProxyResponseHandler; +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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Service which handles SOAP responses from OSGP. The SOAP response will be set for the connection + * which correlates with the connection-id. + */ +@Service +public class ClientCommunicationService implements ProxyResponseHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(ClientCommunicationService.class); + + /** + * Service used to cache incoming connections from client applications. + */ + @Autowired + private ConnectionCacheService connectionCacheService; + + /** + * Service used to sign and/or verify the content of queue messages. + */ + @Autowired + private SigningService signingService; + + /** + * Process an incoming queue message. The content of the message has to be verified by the + * {@link SigningService}. Then a response from OSGP will set for the pending connection from a + * client. + * + * @param proxyServerResponseMessage The incoming queue message to process. + */ + @Override + public void handleIncomingResponse(final ProxyServerResponseMessage proxyServerResponseMessage) { + final boolean isValid = signingService.verifyContent( + proxyServerResponseMessage.constructString(), proxyServerResponseMessage.getSignature()); + if (!isValid) { + LOGGER.error("ProxyServerResponseMessage failed to pass security check."); + return; + } + + try { + final Connection connection = + connectionCacheService.findConnection(proxyServerResponseMessage.getConnectionId()); + if (connection != null) { + if (isValid) { + LOGGER.debug("Connection valid, set SOAP response"); + connection.setResponse(proxyServerResponseMessage.getSoapResponse()); + } else { + connection.setResponse("Security check has failed."); + } + } else { + LOGGER.error("Cached connection is null"); + } + } catch (final ConnectionNotFoundInCacheException e) { + LOGGER.error("ConnectionNotFoundInCacheException", e); + } + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java b/components/soap/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java new file mode 100644 index 0000000..0c699a5 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/services/ConnectionCacheService.java @@ -0,0 +1,95 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.application.utils.RandomStringFactory; +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; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 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 a random string generated by + * {@link RandomStringFactory}. 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; + } + + /** + * Determine if the response is available for a particular {@link Connection} instance. + * + * @param connectionId The key for the {@link Connection} instance obtained by calling + * {@link ConnectionCacheService#cacheConnection()}. + * @return True in case the response from the Platform has resolved and is available, false + * otherwise. + * @throws ConnectionNotFoundInCacheException In case the connection is not present in the + * {@link ConnectionCacheService#cache}. + */ + public boolean hasResponseResolved(final String connectionId) + throws ConnectionNotFoundInCacheException { + final Connection connection = cache.get(connectionId); + if (connection != null) { + LOGGER.debug( + "hasResponseResolved called with connectionId: {}, this.cache.size(): {}", + connectionId, + cache.size()); + return connection.isResponseResolved(); + } else { + throw new ConnectionNotFoundInCacheException( + String.format("Unable to find connection for connectionId: %s", connectionId)); + } + } + + /** + * 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/components/soap/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java b/components/soap/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java new file mode 100644 index 0000000..60ae07d --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/services/PlatformCommunicationService.java @@ -0,0 +1,66 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.messaging.messages.ProxyServerRequestMessage; +import org.gxf.soapbridge.services.ProxyRequestHandler; +import org.gxf.soapbridge.soap.clients.SoapClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Service which can send SOAP requests to OSGP. + */ +@Service +public class PlatformCommunicationService implements ProxyRequestHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(PlatformCommunicationService.class); + + /** + * SOAP client used to sent request messages to OSGP. + */ + @Autowired + private SoapClient soapClient; + + /** + * Service used to sign and/or verify the content of queue messages. + */ + @Autowired + private 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 OSGP using {@link SoapClient}. + * + * @param proxyServerRequestMessage The incoming queue message to process. + */ + @Override + 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/components/soap/src/main/java/org/gxf/soapbridge/application/services/SigningService.java b/components/soap/src/main/java/org/gxf/soapbridge/application/services/SigningService.java new file mode 100644 index 0000000..ed2241a --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/services/SigningService.java @@ -0,0 +1,86 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.application.properties.SecurityConfigurationProperties; +import org.gxf.soapbridge.application.properties.SigningConfigurationProperties; +import org.gxf.soapbridge.soap.exceptions.ProxyServerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Signature; +import java.util.HexFormat; + +/** + * 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); + + private 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/components/soap/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java b/components/soap/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java new file mode 100644 index 0000000..8f8a5a3 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/services/SslContextCacheService.java @@ -0,0 +1,79 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.services; + +import org.gxf.soapbridge.application.factories.SslContextFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 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. + */ + @Autowired + private 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/components/soap/src/main/java/org/gxf/soapbridge/application/utils/RandomStringFactory.java b/components/soap/src/main/java/org/gxf/soapbridge/application/utils/RandomStringFactory.java new file mode 100644 index 0000000..e5a3e7a --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/application/utils/RandomStringFactory.java @@ -0,0 +1,52 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.utils; + +import java.security.SecureRandom; + +/** + * This class can generate random string values. + */ +public class RandomStringFactory { + + /** + * The random used to generate random strings. + */ + private static final SecureRandom random = new SecureRandom(); + + /** + * Default length of the generated random strings. + */ + private static final int DEFAULT_LENGTH = 16; + + private RandomStringFactory() { + // Only static. + } + + /** + * Generate a random string. + * + * @return A string of length {@link RandomStringFactory#defaultLength} + */ + public static String generateRandomString() { + return randomString(DEFAULT_LENGTH); + } + + /** + * Generate a random string. + * + * @param length The length of the random string to generate. + * @return A string of the given length. + */ + public static String generateRandomString(final int length) { + return randomString(length); + } + + private static String randomString(final int length) { + final String chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + final StringBuilder buf = new StringBuilder(); + for (int i = 0; i < length; i++) { + buf.append(chars.charAt(random.nextInt(chars.length()))); + } + return buf.toString(); + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java new file mode 100644 index 0000000..4780037 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/clients/Connection.java @@ -0,0 +1,72 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.clients; + +import org.gxf.soapbridge.application.services.ConnectionCacheService; +import org.gxf.soapbridge.application.utils.RandomStringFactory; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +public class Connection { + + /** + * Default length for the connection id, which is the key for the + * {@link ConnectionCacheService#cache}. + */ + private static final int CONNECTION_ID_LENGTH = 32; + + private volatile boolean responseResolved; + + private String soapResponse; + + private String connectionId; + + private final Semaphore responseReceived; + + public Connection() { + responseResolved = false; + responseReceived = new Semaphore(0); + connectionId = RandomStringFactory.generateRandomString(CONNECTION_ID_LENGTH); + } + + public boolean isResponseResolved() { + return responseResolved; + } + + public void setResponse(final String soapResponse) { + responseResolved = true; + this.soapResponse = soapResponse; + responseReceived(); + } + + public String getSoapResponse() { + return soapResponse; + } + + public void setConnectionId(final String connectionId) { + this.connectionId = connectionId; + } + + 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. + * + * @timeout The number of seconds to wait for a response. + * + * @returns 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/components/soap/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java new file mode 100644 index 0000000..7f92f83 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/clients/SoapClient.java @@ -0,0 +1,173 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.clients; + +import org.gxf.soapbridge.application.factories.HttpsUrlConnectionFactory; +import org.gxf.soapbridge.application.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.application.properties.SoapEndpointConfiguration; +import org.gxf.soapbridge.application.services.SigningService; +import org.gxf.soapbridge.messaging.ProxyResponsesMessageSender; +import org.gxf.soapbridge.messaging.messages.ProxyServerResponseMessage; +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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.net.ssl.HttpsURLConnection; +import java.io.*; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; + +/** + * 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. + */ + @Autowired + private ProxyResponsesMessageSender callResponsesMessageSender; + + @Autowired + private SoapConfigurationProperties soapConfiguration; + + /** + * Factory which assist in creating {@link HttpsURLConnection} instances. + */ + @Autowired + private HttpsUrlConnectionFactory httpsUrlConnectionFactory; + + /** + * Service used to sign the content of a message. + */ + @Autowired + private 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. + callResponsesMessageSender.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.info("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/components/soap/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java new file mode 100644 index 0000000..7638797 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/endpoints/SoapEndpoint.java @@ -0,0 +1,440 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.endpoints; + +import jakarta.annotation.PostConstruct; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.gxf.soapbridge.application.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.application.services.ConnectionCacheService; +import org.gxf.soapbridge.application.services.SigningService; +import org.gxf.soapbridge.messaging.ProxyRequestsMessageSender; +import org.gxf.soapbridge.messaging.messages.ProxyServerRequestMessage; +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.soap.valueobjects.ClientCertificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.stereotype.Component; +import org.springframework.web.HttpRequestHandler; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.ldap.Rdn; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.xpath.*; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.*; + +/** + * 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 SOAP_HEADER_KEY_APPLICATION_NAME = "ApplicationName"; + private static final String SOAP_HEADER_KEY_ORGANISATION_IDENTIFICATION = "OrganisationIdentification"; + private static final String SOAP_HEADER_KEY_USER_NAME = "UserName"; + private static final List SOAP_HEADER_KEYS = List.of( + SOAP_HEADER_KEY_APPLICATION_NAME, + SOAP_HEADER_KEY_ORGANISATION_IDENTIFICATION, + SOAP_HEADER_KEY_USER_NAME); + + private static final String URL_PROXY_SERVER = "/proxy-server"; + + private static final int INVALID_CUSTOM_TIME_OUT = -1; + public static final String X_509_CERTIFICATE_REQUEST_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; + + /** + * Service used to cache incoming connections from client applications. + */ + @Autowired + private ConnectionCacheService connectionCacheService; + + @Autowired + private SoapConfigurationProperties soapConfiguration; + + /** + * Message sender which can send a webapp request message to ActiveMQ. + */ + @Autowired + private ProxyRequestsMessageSender proxyRequestsMessageSender; + + /** + * Service used to sign the content of a message. + */ + @Autowired + private SigningService signingService; + + /** + * Map of time outs for specific functions. + */ + private final Map customTimeOutsMap = new HashMap<>(); + + @PostConstruct + public void init() { + final String[] split = soapConfiguration.getCustomTimeouts().split(","); + for (int i = 0; i < split.length; i += 2) { + final String key = split[i]; + final Integer value = Integer.valueOf(split[i + 1]); + LOGGER.debug("Adding custom time out with key: {} and value: {}", key, value); + customTimeOutsMap.put(key, value); + } + LOGGER.debug("Added {} custom time outs to the map", customTimeOutsMap.size()); + } + + /** + * Handles incoming SOAP requests. + */ + @Override + public void handleRequest(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + // For debugging, print all headers and parameters. + LOGGER.debug("Start of SoapEndpoint.handleRequest()"); + printHeaderValues(request); + printParameterValues(request); + + // Get the context, which should be an OSGP 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(RequestAttributeSecurityContextRepository.DEFAULT_REQUEST_ATTR_NAME) instanceof final SecurityContext securityContext) { + if (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 time out: {} seconds", timeOut); + } else { + LOGGER.debug("Using custom time out: {} seconds", customTimeOut); + timeOut = customTimeOut; + } + + try { + proxyRequestsMessageSender.send(requestMessage); + + final boolean responseReceived = newConnection.waitForResponseReceived(timeOut); + if (!responseReceived) { + LOGGER.info("No response received within the specified time out of {} seconds", timeOut); + createErrorResponse(response); + connectionCacheService.removeConnection(connectionId); + return; + } + } catch (final Exception e) { + LOGGER.info("Error while waiting for response", e); + createErrorResponse(response); + connectionCacheService.removeConnection(connectionId); + 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."); + } + + public String[] sander() { + return new String[]{"a", "poipoi", "frats"}; + } + + private void printHeaderValues(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 printParameterValues(final HttpServletRequest request) { + if (LOGGER.isDebugEnabled()) { + for (final Enumeration parameterNames = request.getParameterNames(); + parameterNames.hasMoreElements(); ) { + final String parameterName = parameterNames.nextElement(); + final String[] parameterValues = request.getParameterValues(parameterName); + String str = ""; + for (final String parameterValue : parameterValues) { + str = str.concat(parameterValue).concat(" "); + } + LOGGER.debug(" parameter name: {} parameter value: {}", parameterName, str); + } + } + } + + 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 Map getSoapHeaderValues( + final String soapPayload, final List soapHeaderKeys) { + final Map values = new HashMap<>(); + try { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setNamespaceAware(false); + final InputStream inputStream = + new ByteArrayInputStream(soapPayload.getBytes(StandardCharsets.UTF_8)); + // Try to find the desired XML elements in the document. + final Document document = factory.newDocumentBuilder().parse(inputStream); + for (final String soapHeaderKey : soapHeaderKeys) { + final String value = evaluateXPathExpression(document, soapHeaderKey); + values.put(soapHeaderKey, value); + } + inputStream.close(); + } catch (final Exception e) { + LOGGER.error("Exception", e); + } + return values; + } + + /** + * Search an XML element using an XPath expression. + * + * @param document The XML document. + * @param element The name of the desired XML element. + * @return The content of the XML element, or null if the element is not found. + * @throws XPathExpressionException In case the expression fails to compile or evaluate, an + * exception will be thrown. + */ + private String evaluateXPathExpression(final Document document, final String element) + throws XPathExpressionException { + final String expression = String.format("//*[contains(local-name(), '%s')]", element); + + final XPathFactory xFactory = XPathFactory.newInstance(); + final XPath xPath = xFactory.newXPath(); + + final XPathExpression xPathExpression = xPath.compile(expression); + final NodeList nodeList = (NodeList) xPathExpression.evaluate(document, XPathConstants.NODESET); + + if (nodeList != null && nodeList.getLength() > 0) { + return nodeList.item(0).getTextContent(); + } else { + return null; + } + } + + private ClientCertificate tryToFindClientCertificate( + final HttpServletRequest request, final String soapPayload) { + final X509Certificate[] x509Certificates = getX509CertificatesFromServlet(request); + ClientCertificate clientCertificate = null; + + if (x509Certificates.length == 0) { + LOGGER.error(" HTTPServletRequest's attribute was an empty array of X509Certificates."); + } else if (x509Certificates.length == 1) { + LOGGER.debug(" HTTPServletRequest's attribute was array of X509Certificates of length 1."); + + // Get the client certificate. + clientCertificate = getClientCertificate(x509Certificates[0]); + } else { + LOGGER.debug( + " HTTPServletRequest's attribute was array of X509Certificates of length {}.", + x509Certificates.length); + + final Map soapHeaderValues = + getSoapHeaderValues(soapPayload, SOAP_HEADER_KEYS); + LOGGER.debug( + " SOAP Header ApplicationName: {}", + soapHeaderValues.get(SOAP_HEADER_KEY_APPLICATION_NAME)); + LOGGER.debug( + " SOAP Header OrganisationIdentification: {}", + soapHeaderValues.get(SOAP_HEADER_KEY_ORGANISATION_IDENTIFICATION)); + LOGGER.debug(" SOAP Header UserName: {}", soapHeaderValues.get(SOAP_HEADER_KEY_USER_NAME)); + + // Try to extract the client certificate for the organization + // identification. + clientCertificate = + getClientCertificateByOrganisationIdentification( + x509Certificates, soapHeaderValues.get(SOAP_HEADER_KEY_ORGANISATION_IDENTIFICATION)); + } + + return clientCertificate; + } + + private X509Certificate[] getX509CertificatesFromServlet(final HttpServletRequest request) { + final Object x509CertificateAttribute = request.getAttribute(X_509_CERTIFICATE_REQUEST_ATTRIBUTE); + LOGGER.debug(X_509_CERTIFICATE_REQUEST_ATTRIBUTE + ": {}", x509CertificateAttribute); + if (x509CertificateAttribute instanceof X509Certificate[]) { + LOGGER.debug(" x509CertificateAttribute instanceof X509Certificate[]"); + return (X509Certificate[]) x509CertificateAttribute; + } else { + return new X509Certificate[0]; + } + } + + private ClientCertificate getClientCertificateByOrganisationIdentification( + final X509Certificate[] array, final String organisationCommonName) { + + for (final X509Certificate x509Certificate : array) { + final Principal principal = x509Certificate.getSubjectDN(); + LOGGER.debug(" principal: {}", principal); + final String subjectDn = principal.getName(); + LOGGER.debug(" subjectDn: {}", subjectDn); + try { + final String commonName = getCommonName(subjectDn); + if (commonName.equals(organisationCommonName)) { + LOGGER.debug( + "Found client certificate for right organisation {}", organisationCommonName); + return new ClientCertificate(x509Certificate, commonName); + } + } catch (final NamingException e) { + LOGGER.info("Failed to extract CommonName from this ClientCertificate", e); + } + } + return null; + } + + private ClientCertificate getClientCertificate(final X509Certificate x509Certificate) { + ClientCertificate clientCertificate = null; + + final Principal principal = x509Certificate.getSubjectDN(); + LOGGER.debug(" principal: {}", principal); + final String subjectDn = principal.getName(); + LOGGER.debug(" subjectDn: {}", subjectDn); + try { + final String commonName = getCommonName(subjectDn); + clientCertificate = new ClientCertificate(x509Certificate, commonName); + } catch (final NamingException e) { + LOGGER.info("Failed to extract CommonName from ClientCertificate", e); + } + + return clientCertificate; + } + + private String getCommonName(final String subjectDn) throws NamingException { + final Rdn rdn = new Rdn(subjectDn); + LOGGER.debug(" rdn: {}", rdn); + final Attributes attributes = rdn.toAttributes(); + LOGGER.debug(" attributes: {}", attributes); + final Attribute attribute = attributes.get("cn"); + LOGGER.debug(" attribute: {}", attribute); + final String commonName = (String) attribute.get(); + LOGGER.debug(" common name: {}", commonName); + return commonName; + } + + 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("SOAP-ACTION", ""); + response.addHeader("Keep-Alive", "timeout=5, max=100"); + response.addHeader("Accept", "text/xml"); + response.addHeader("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/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java new file mode 100644 index 0000000..fe8701a --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ConnectionNotFoundInCacheException.java @@ -0,0 +1,14 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.exceptions; + +public class ConnectionNotFoundInCacheException extends ProxyServerException { + + /** + * Serial Version UID. + */ + private static final long serialVersionUID = -858760086093512799L; + + public ConnectionNotFoundInCacheException(final String message) { + super(message); + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java new file mode 100644 index 0000000..33d801c --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/ProxyServerException.java @@ -0,0 +1,25 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.exceptions; + +/** + * Base type for exceptions for proxy server component. + */ +public class ProxyServerException extends Exception { + + /** + * Serial Version UID. + */ + private static final long serialVersionUID = -8696835428244659385L; + + public ProxyServerException() { + super(); + } + + public ProxyServerException(final String message) { + super(message); + } + + public ProxyServerException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java new file mode 100644 index 0000000..5ab543e --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateHttpsURLConnectionException.java @@ -0,0 +1,14 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.exceptions; + +public class UnableToCreateHttpsURLConnectionException extends ProxyServerException { + + /** + * Serial Version UID. + */ + private static final long serialVersionUID = -8807766325167125880L; + + public UnableToCreateHttpsURLConnectionException(final String message) { + super(message); + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java new file mode 100644 index 0000000..a497607 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateKeyManagersException.java @@ -0,0 +1,14 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.exceptions; + +public class UnableToCreateKeyManagersException extends ProxyServerException { + + /** + * Serial Version UID. + */ + private static final long serialVersionUID = -100586751704652623L; + + public UnableToCreateKeyManagersException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java new file mode 100644 index 0000000..0c54802 --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/exceptions/UnableToCreateTrustManagersException.java @@ -0,0 +1,14 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.exceptions; + +public class UnableToCreateTrustManagersException extends ProxyServerException { + + /** + * Serial Version UID. + */ + private static final long serialVersionUID = -855694158211466200L; + + public UnableToCreateTrustManagersException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/components/soap/src/main/java/org/gxf/soapbridge/soap/valueobjects/ClientCertificate.java b/components/soap/src/main/java/org/gxf/soapbridge/soap/valueobjects/ClientCertificate.java new file mode 100644 index 0000000..8643c2d --- /dev/null +++ b/components/soap/src/main/java/org/gxf/soapbridge/soap/valueobjects/ClientCertificate.java @@ -0,0 +1,24 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.valueobjects; + +import java.security.cert.X509Certificate; + +public class ClientCertificate { + + private final X509Certificate x509Certificate; + + private final String commonName; + + public ClientCertificate(final X509Certificate x509Certificate, final String commonName) { + this.x509Certificate = x509Certificate; + this.commonName = commonName; + } + + public X509Certificate getX509Certificate() { + return x509Certificate; + } + + public String getCommonName() { + return commonName; + } +} diff --git a/components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SecurityConfigurationProperties.kt b/components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SecurityConfigurationProperties.kt new file mode 100644 index 0000000..fd7ce79 --- /dev/null +++ b/components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SecurityConfigurationProperties.kt @@ -0,0 +1,77 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.application.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( + val keyType: String, + val signKeyFile: String, + val 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/components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SoapConfigurationProperties.kt b/components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SoapConfigurationProperties.kt new file mode 100644 index 0000000..ba81758 --- /dev/null +++ b/components/soap/src/main/kotlin/org/gxf/soapbridge/application/properties/SoapConfigurationProperties.kt @@ -0,0 +1,34 @@ +// Copyright 2023 Alliander N.V. + +package org.gxf.soapbridge.application.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, + /** + * Time outs for specific functions. + */ + val customTimeouts: String, + val callEndpoint: SoapEndpointConfiguration, +) + +enum class HostnameVerificationStrategy { + ALLOW_ALL_HOSTNAMES, BROWSER_COMPATIBLE_HOSTNAMES +} + +class SoapEndpointConfiguration( + host: String, + port: Int, + protocol: String +) { + + val hostAndPort = "$host:$port" + val uri = "$protocol://${hostAndPort}" +} diff --git a/components/soap/src/test/java/org/gxf/soapbridge/application/utils/RandomStringServiceTests.java b/components/soap/src/test/java/org/gxf/soapbridge/application/utils/RandomStringServiceTests.java new file mode 100644 index 0000000..d834d4e --- /dev/null +++ b/components/soap/src/test/java/org/gxf/soapbridge/application/utils/RandomStringServiceTests.java @@ -0,0 +1,32 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.application.utils; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class RandomStringServiceTests { + + @Test + void test1() { + final String randomString = RandomStringFactory.generateRandomString(); + + assertNotNull(randomString, "It is expected that the generated random string not is null"); + assertEquals( + 16, + randomString.length(), + "It is expected that the generated random string is of length 16"); + } + + @Test + void test2() { + final String randomString = RandomStringFactory.generateRandomString(42); + + assertNotNull(randomString, "It is expected that the generated random string not is null"); + assertEquals( + 42, + randomString.length(), + "It is expected that the generated random string is of length 42"); + } +} diff --git a/components/soap/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java b/components/soap/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java new file mode 100644 index 0000000..ed952d3 --- /dev/null +++ b/components/soap/src/test/java/org/gxf/soapbridge/soap/clients/SoapClientTest.java @@ -0,0 +1,106 @@ +// Copyright 2023 Alliander N.V. +package org.gxf.soapbridge.soap.clients; + +import org.gxf.soapbridge.application.factories.HttpsUrlConnectionFactory; +import org.gxf.soapbridge.application.properties.HostnameVerificationStrategy; +import org.gxf.soapbridge.application.properties.SoapConfigurationProperties; +import org.gxf.soapbridge.application.properties.SoapEndpointConfiguration; +import org.gxf.soapbridge.application.services.SigningService; +import org.gxf.soapbridge.messaging.ProxyResponsesMessageSender; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.net.ssl.HttpsURLConnection; +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 static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SoapClientTest { + + @Mock + ProxyResponsesMessageSender proxyResponsesMessageSender; + @Mock + HttpsUrlConnectionFactory httpsUrlConnectionFactory; + @Mock + SigningService signingService; + + byte[] testContent = "test content".getBytes(StandardCharsets.UTF_8); + + @Spy + SoapConfigurationProperties soapConfigurationProperties = new SoapConfigurationProperties( + HostnameVerificationStrategy.BROWSER_COMPATIBLE_HOSTNAMES, + 45, + "", + new SoapEndpointConfiguration( + "localhost", 443, "https" + ) + ); + + @InjectMocks + SoapClient soapClient; + + @Test + void shouldSendSoapRequestAndJmsResponse() throws Exception { + // arrange + final HttpsURLConnection connection = setupConnectionMock(); + when(httpsUrlConnectionFactory.createConnection( + anyString(), anyString(), anyString(), anyString())) + .thenReturn(connection); + + // act + soapClient.sendRequest( + "connectionId", + "context", + "commonName", + "payload"); + + // assert + verify(connection).disconnect(); + } + + @Test + void shoudDisconnectWhenSoapRequestFails() throws Exception { + // arrange + final HttpsURLConnection connection = setupFailingConnectionMock(); + when(httpsUrlConnectionFactory.createConnection( + anyString(), anyString(), anyString(), anyString())) + .thenReturn(connection); + + // act + soapClient.sendRequest( + "connectionId", + "context", + "commonName", + "payload"); + + // assert + verify(connection).disconnect(); + verifyNoInteractions(proxyResponsesMessageSender); + } + + private HttpsURLConnection setupConnectionMock() throws Exception { + final HttpsURLConnection connection = mock(HttpsURLConnection.class); + final InputStream inputStream = new ByteArrayInputStream(testContent); + when(connection.getOutputStream()).thenReturn(mock(OutputStream.class)); + when(connection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + when(connection.getInputStream()).thenReturn(inputStream); + return connection; + } + + private HttpsURLConnection setupFailingConnectionMock() throws Exception { + final HttpsURLConnection connection = mock(HttpsURLConnection.class); + when(connection.getOutputStream()).thenThrow(ConnectException.class); + return connection; + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..e9652be --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,35 @@ +# 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" + schema-registry: + image: confluentinc/cp-schema-registry:7.3.0 + depends_on: + - zookeeper + - kafka + ports: + - "8081:8081" + environment: + SCHEMA_REGISTRY_HOST_NAME: schema-registry + SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: 'kafka:29092' + SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081 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 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + 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..91fd055 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,8 @@ +// Copyright 2023 Alliander N.V. + +rootProject.name = "gxf-soap-bridge" + +include("application") +include("components:core") +include("components:kafka") +include("components:soap")