diff --git a/.github/workflows/on_pull_request.yml b/.github/workflows/on_pull_request.yml index 428f7bd1c..af490ab50 100644 --- a/.github/workflows/on_pull_request.yml +++ b/.github/workflows/on_pull_request.yml @@ -27,7 +27,7 @@ jobs: complete: if: always() needs: [ gradle_build, essential_tests, extended_tests, rust_build ] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 diff --git a/.github/workflows/sub_extended_tests.yml b/.github/workflows/sub_extended_tests.yml index f9e1a694c..1d782f703 100644 --- a/.github/workflows/sub_extended_tests.yml +++ b/.github/workflows/sub_extended_tests.yml @@ -157,7 +157,7 @@ jobs: uses: mydea/action-wait-for-api@v1 with: url: "http://localhost:8085/health" - expected-status: "403" + expected-status: "200" interval: "1" timeout: "600" @@ -183,7 +183,7 @@ jobs: uses: mydea/action-wait-for-api@v1 with: url: "http://localhost:8085/health" - expected-status: "403" + expected-status: "200" interval: "1" timeout: "600" diff --git a/README.md b/README.md index 0dcde9fce..2139bc0df 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![License](https://badgen.net/badge/license/Apache%202/blue?icon=github&label=License)](https://github.com/stellar/anchor-platform/blob/develop/LICENSE) [![GitHub Version](https://badgen.net/github/release/stellar/anchor-platform?icon=github&label=Latest%20release)](https://github.com/stellar/anchor-platform/releases) -[![Docker](https://badgen.net/badge/Latest%20Release/v4.1.6/blue?icon=docker)](https://hub.docker.com/r/stellar/anchor-platform/tags?page=1&name=4.1.6) +[![Docker](https://badgen.net/badge/Latest%20Release/v4.1.7/blue?icon=docker)](https://hub.docker.com/r/stellar/anchor-platform/tags?page=1&name=4.1.7) ![Develop Branch](https://github.com/stellar/anchor-platform/actions/workflows/on_push_to_develop.yml/badge.svg?branch=develop)
diff --git a/build.gradle.kts b/build.gradle.kts index e926a428f..2b02bd2c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -213,7 +213,7 @@ subprojects { allprojects { group = "org.stellar.anchor-sdk" - version = "4.1.6" + version = "4.1.7" tasks.jar { manifest { diff --git a/core/src/main/java/org/stellar/anchor/filter/AbstractJwtFilter.java b/core/src/main/java/org/stellar/anchor/filter/AbstractJwtFilter.java index 533579f76..89f4bea1a 100644 --- a/core/src/main/java/org/stellar/anchor/filter/AbstractJwtFilter.java +++ b/core/src/main/java/org/stellar/anchor/filter/AbstractJwtFilter.java @@ -49,6 +49,11 @@ public void doFilter( request.getRequestURL().toString(), request.getQueryString()); + if (shouldSkip(request)) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (request.getMethod().equals("OPTIONS")) { filterChain.doFilter(servletRequest, servletResponse); return; @@ -88,6 +93,10 @@ public abstract void check( String jwtCipher, HttpServletRequest request, ServletResponse servletResponse) throws Exception; + protected boolean shouldSkip(HttpServletRequest request) { + return false; + } + private static void sendForbiddenError(HttpServletResponse response) throws IOException { error("Forbidden: JwtTokenFilter failed to authenticate the request."); response.setStatus(HttpStatus.SC_FORBIDDEN); diff --git a/core/src/main/java/org/stellar/anchor/filter/ApiKeyFilter.java b/core/src/main/java/org/stellar/anchor/filter/ApiKeyFilter.java index d303e34b1..5918e9e94 100644 --- a/core/src/main/java/org/stellar/anchor/filter/ApiKeyFilter.java +++ b/core/src/main/java/org/stellar/anchor/filter/ApiKeyFilter.java @@ -47,6 +47,11 @@ public void doFilter( request.getRequestURL().toString(), request.getQueryString()); + if (shouldSkip(request)) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (request.getMethod().equals(OPTIONS)) { filterChain.doFilter(servletRequest, servletResponse); return; @@ -63,6 +68,10 @@ public void doFilter( filterChain.doFilter(servletRequest, servletResponse); } + protected boolean shouldSkip(HttpServletRequest request) { + return "/health".equals(FilterUtils.getRequestPath(request)); + } + private static void sendForbiddenError(HttpServletResponse response) throws IOException { error("Forbidden: ApiKeyFilter failed to authenticate the request."); response.setStatus(HttpStatus.SC_FORBIDDEN); diff --git a/core/src/main/java/org/stellar/anchor/filter/FilterUtils.java b/core/src/main/java/org/stellar/anchor/filter/FilterUtils.java new file mode 100644 index 000000000..9f7e499af --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/filter/FilterUtils.java @@ -0,0 +1,31 @@ +package org.stellar.anchor.filter; + +import jakarta.servlet.http.HttpServletRequest; + +final class FilterUtils { + static String getRequestPath(HttpServletRequest request) { + String servletPath = request.getServletPath(); + if (servletPath != null && !servletPath.isEmpty()) { + return servletPath; + } + + String pathInfo = request.getPathInfo(); + if (pathInfo != null && !pathInfo.isEmpty()) { + return pathInfo; + } + + String requestUri = request.getRequestURI(); + if (requestUri == null) { + return ""; + } + + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && requestUri.startsWith(contextPath)) { + return requestUri.substring(contextPath.length()); + } + + return requestUri; + } + + private FilterUtils() {} +} diff --git a/core/src/main/java/org/stellar/anchor/filter/PlatformAuthJwtFilter.java b/core/src/main/java/org/stellar/anchor/filter/PlatformAuthJwtFilter.java index 21c89ac6f..62a0f8bd4 100644 --- a/core/src/main/java/org/stellar/anchor/filter/PlatformAuthJwtFilter.java +++ b/core/src/main/java/org/stellar/anchor/filter/PlatformAuthJwtFilter.java @@ -12,6 +12,11 @@ public PlatformAuthJwtFilter(JwtService jwtService, String authorizationHeader) super(jwtService, authorizationHeader); } + @Override + protected boolean shouldSkip(HttpServletRequest request) { + return "/health".equals(FilterUtils.getRequestPath(request)); + } + @Override public void check(String jwtCipher, HttpServletRequest request, ServletResponse servletResponse) throws Exception { diff --git a/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java b/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java index 9e41f8076..75e6350c6 100644 --- a/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java +++ b/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java @@ -70,7 +70,7 @@ public Sep12GetCustomerResponse getCustomer(WebAuthJwt token, Sep12GetCustomerRe populateRequestFromTransactionId(request); validateGetOrPutRequest(request, token); - if (request.getId() == null && request.getAccount() == null && token.getAccount() != null) { + if (request.getAccount() == null && token.getAccount() != null) { request.setAccount(token.getAccount()); } diff --git a/core/src/test/kotlin/org/stellar/anchor/filter/ApiKeyFilterTest.kt b/core/src/test/kotlin/org/stellar/anchor/filter/ApiKeyFilterTest.kt index daad797e0..c1bf4d97e 100644 --- a/core/src/test/kotlin/org/stellar/anchor/filter/ApiKeyFilterTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/filter/ApiKeyFilterTest.kt @@ -111,6 +111,18 @@ internal class ApiKeyFilterTest { verify { mockFilterChain.doFilter(request, response) } } + @Test + fun `health endpoint bypasses api key auth`() { + every { request.method } returns "GET" + every { request.servletPath } returns "/health" + every { request.getHeader("X-Api-Key") } returns null + + apiKeyFilter.doFilter(request, response, mockFilterChain) + + verify { mockFilterChain.doFilter(request, response) } + verify(exactly = 0) { response.setStatus(HttpStatus.SC_FORBIDDEN) } + } + @ParameterizedTest @ValueSource(strings = ["GET", "PUT", "POST", "DELETE"]) fun `make sure FORBIDDEN is returned when the filter requires header names other than X-Api-Key`( diff --git a/core/src/test/kotlin/org/stellar/anchor/filter/PlatformAuthJwtFilterTest.kt b/core/src/test/kotlin/org/stellar/anchor/filter/PlatformAuthJwtFilterTest.kt new file mode 100644 index 000000000..b4d1bb5ab --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/filter/PlatformAuthJwtFilterTest.kt @@ -0,0 +1,42 @@ +package org.stellar.anchor.filter + +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.apache.hc.core5.http.HttpStatus +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.stellar.anchor.auth.JwtService + +internal class PlatformAuthJwtFilterTest { + private lateinit var jwtService: JwtService + private lateinit var request: HttpServletRequest + private lateinit var response: HttpServletResponse + private lateinit var mockFilterChain: FilterChain + + @BeforeEach + fun setup() { + this.jwtService = mockk(relaxed = true) + this.request = mockk(relaxed = true) + this.response = mockk(relaxed = true) + this.mockFilterChain = mockk(relaxed = true) + } + + @Test + fun `health endpoint bypasses jwt auth`() { + every { request.method } returns "GET" + every { request.servletPath } returns "/health" + every { request.getHeader("Authorization") } returns null + val filter = spyk(PlatformAuthJwtFilter(jwtService, "Authorization")) + + filter.doFilter(request, response, mockFilterChain) + + verify { mockFilterChain.doFilter(request, response) } + verify(exactly = 0) { response.setStatus(HttpStatus.SC_FORBIDDEN) } + verify(exactly = 0) { filter.check(any(), any(), any()) } + } +} diff --git a/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt index 40cb57dd7..51ac0de60 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt @@ -516,6 +516,43 @@ class Sep12ServiceTest { assertEquals(TEST_ACCOUNT, mockGetRequest.account) } + @Test + fun `Test get customer request with id injects account`() { + val callbackApiGetRequestSlot = slot() + val mockCallbackApiGetCustomerResponse = GetCustomerResponse() + mockCallbackApiGetCustomerResponse.id = "customer-id" + every { customerIntegration.getCustomer(capture(callbackApiGetRequestSlot)) } returns + mockCallbackApiGetCustomerResponse + + val mockGetRequest = Sep12GetCustomerRequest.builder().id("customer-id").build() + val jwtToken = createJwtToken(TEST_ACCOUNT) + + assertDoesNotThrow { sep12Service.getCustomer(jwtToken, mockGetRequest) } + + val wantCallbackApiGetRequest = + GetCustomerRequest.builder().id("customer-id").account(TEST_ACCOUNT).build() + assertEquals(wantCallbackApiGetRequest, callbackApiGetRequestSlot.captured) + assertEquals(TEST_ACCOUNT, mockGetRequest.account) + } + + @Test + fun `Test put customer request with id injects account`() { + val callbackApiPutRequestSlot = slot() + val mockCallbackApiPutCustomerResponse = PutCustomerResponse.builder().id("customer-id").build() + every { customerIntegration.putCustomer(capture(callbackApiPutRequestSlot)) } returns + mockCallbackApiPutCustomerResponse + + val mockPutRequest = Sep12PutCustomerRequest.builder().id("customer-id").build() + val jwtToken = createJwtToken(TEST_ACCOUNT) + + assertDoesNotThrow { sep12Service.putCustomer(jwtToken, mockPutRequest) } + + val wantCallbackApiPutRequest = + PutCustomerRequest.builder().id("customer-id").account(TEST_ACCOUNT).build() + assertEquals(wantCallbackApiPutRequest, callbackApiPutRequestSlot.captured) + assertEquals(TEST_ACCOUNT, mockPutRequest.account) + } + @Test fun `test delete customer validation`() { every { customerIntegration.deleteCustomer(any()) } just Runs diff --git a/docs/resources/deployment-examples/example-fargate/Dockerfile b/docs/resources/deployment-examples/example-fargate/Dockerfile index eb73d28fb..e906fa784 100644 --- a/docs/resources/deployment-examples/example-fargate/Dockerfile +++ b/docs/resources/deployment-examples/example-fargate/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 RUN mkdir /config_files ENV STELLAR_ANCHOR_CONFIG=file:/config/anchor-config.yaml ENV REFERENCE_SERVER_CONFIG_ENV=file:/config/reference-config.yaml diff --git a/helm-charts/reference-server/templates/configmap.yaml b/helm-charts/reference-server/templates/configmap.yaml index 28cb5a7ae..8358aa9c5 100644 --- a/helm-charts/reference-server/templates/configmap.yaml +++ b/helm-charts/reference-server/templates/configmap.yaml @@ -16,7 +16,7 @@ data: custodyEnabled: false auth: - type: NONE + type: {{ .Values.config.auth.type | default "NONE" }} data: - database: postgres \ No newline at end of file + database: postgres diff --git a/helm-charts/reference-server/values.yaml b/helm-charts/reference-server/values.yaml index 34185dc4b..cb79d6ae5 100644 --- a/helm-charts/reference-server/values.yaml +++ b/helm-charts/reference-server/values.yaml @@ -13,6 +13,8 @@ config: rpcEndpoint: https://soroban-testnet.stellar.org platformApiEndpoint: http://anchor-platform-svc-platform:8085 rpcEnabled: false + auth: + type: NONE data: url: postgresql-ref:5432 @@ -37,4 +39,4 @@ ingress: host: reference-server.local className: nginx annotations: - nginx.ingress.kubernetes.io/rewrite-target: / \ No newline at end of file + nginx.ingress.kubernetes.io/rewrite-target: / diff --git a/helm-charts/secret-store/templates/secretstore.yaml b/helm-charts/secret-store/templates/secretstore.yaml index c43bc0c45..714fe819e 100644 --- a/helm-charts/secret-store/templates/secretstore.yaml +++ b/helm-charts/secret-store/templates/secretstore.yaml @@ -21,8 +21,10 @@ spec: "SEP10_SIGNING_SEED": "{{ .Values.sep10_signing_seed }}", "EVENTS_QUEUE_KAFKA_USERNAME": "user1", "EVENTS_QUEUE_KAFKA_PASSWORD": "123456789", - "SENTRY_AUTH_TOKEN": {{ default "\"\"" .Values.sentry_auth_token }} - "SEP45_JWT_SECRET": "1e5ba4a1da18dc6462b21bb720a5aceddb34a421e732e0f6615ad30b4a4aa50f" + "SENTRY_AUTH_TOKEN": {{ default "\"\"" .Values.sentry_auth_token }}, + "SEP45_JWT_SECRET": "1e5ba4a1da18dc6462b21bb720a5aceddb34a421e732e0f6615ad30b4a4aa50f", + "CALLBACK_API_AUTH_SECRET": "myPlatformToAnchorSecret", + "PLATFORM_API_AUTH_SECRET": "myAnchorToPlatformSecret" } - key: {{ .Values.namespace }}/reference-server-secrets value: | @@ -32,5 +34,5 @@ spec: "PAYMENT_SIGNING_SEED": "{{ .Values.payment_signing_seed }}", "SEP24_INTERACTIVE_JWT_KEY": "c5457e3a349df9002117543efa7e316dd89e666a5ce6f33a0deb13e90f3f1e9d", "PLATFORM_ANCHOR_SECRET": "myPlatformToAnchorSecret", - "ANCHOR_PLATFORM_SECRET": "myAnchorToPlatformSecret", - } \ No newline at end of file + "ANCHOR_PLATFORM_SECRET": "myAnchorToPlatformSecret" + } diff --git a/helm-charts/sep-service/templates/eventprocessor-deployment.yaml b/helm-charts/sep-service/templates/eventprocessor-deployment.yaml index e31e587b0..4c5127f07 100644 --- a/helm-charts/sep-service/templates/eventprocessor-deployment.yaml +++ b/helm-charts/sep-service/templates/eventprocessor-deployment.yaml @@ -68,6 +68,16 @@ spec: secretKeyRef: name: {{ .Values.fullName }}-secrets key: SEP6_MORE_INFO_URL_JWT_SECRET + - name: SECRET_CALLBACK_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: CALLBACK_API_AUTH_SECRET + - name: SECRET_PLATFORM_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: PLATFORM_API_AUTH_SECRET - name: SECRET_EVENTS_QUEUE_KAFKA_USERNAME valueFrom: secretKeyRef: diff --git a/helm-charts/sep-service/templates/externalsecrets.yaml b/helm-charts/sep-service/templates/externalsecrets.yaml index 22da08dbd..02b27e52e 100644 --- a/helm-charts/sep-service/templates/externalsecrets.yaml +++ b/helm-charts/sep-service/templates/externalsecrets.yaml @@ -54,3 +54,11 @@ spec: remoteRef: key: {{ .Values.namespace }}/{{ .Values.fullName }}-secrets property: SEP45_JWT_SECRET + - secretKey: CALLBACK_API_AUTH_SECRET + remoteRef: + key: {{ .Values.namespace }}/{{ .Values.fullName }}-secrets + property: CALLBACK_API_AUTH_SECRET + - secretKey: PLATFORM_API_AUTH_SECRET + remoteRef: + key: {{ .Values.namespace }}/{{ .Values.fullName }}-secrets + property: PLATFORM_API_AUTH_SECRET diff --git a/helm-charts/sep-service/templates/observer-deployment.yaml b/helm-charts/sep-service/templates/observer-deployment.yaml index 2b03c729e..fd975a9bf 100644 --- a/helm-charts/sep-service/templates/observer-deployment.yaml +++ b/helm-charts/sep-service/templates/observer-deployment.yaml @@ -88,6 +88,16 @@ spec: secretKeyRef: name: {{ .Values.fullName }}-secrets key: SEP6_MORE_INFO_URL_JWT_SECRET + - name: SECRET_CALLBACK_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: CALLBACK_API_AUTH_SECRET + - name: SECRET_PLATFORM_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: PLATFORM_API_AUTH_SECRET - name: SENTRY_AUTH_TOKEN valueFrom: secretKeyRef: @@ -103,4 +113,4 @@ spec: volumes: - name: config-volume configMap: - name: anchor-config \ No newline at end of file + name: anchor-config diff --git a/helm-charts/sep-service/templates/platform-deployment.yaml b/helm-charts/sep-service/templates/platform-deployment.yaml index 1c4e9f685..228e14b3e 100644 --- a/helm-charts/sep-service/templates/platform-deployment.yaml +++ b/helm-charts/sep-service/templates/platform-deployment.yaml @@ -118,6 +118,16 @@ spec: name: {{ .Values.fullName }}-secrets key: SEP45_JWT_SECRET optional: true + - name: SECRET_CALLBACK_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: CALLBACK_API_AUTH_SECRET + - name: SECRET_PLATFORM_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: PLATFORM_API_AUTH_SECRET resources: requests: memory: {{ .Values.services.platform.deployment.resources.requests.memory }} diff --git a/helm-charts/sep-service/templates/sepserver-deployment.yaml b/helm-charts/sep-service/templates/sepserver-deployment.yaml index a40c01f52..b79ade549 100644 --- a/helm-charts/sep-service/templates/sepserver-deployment.yaml +++ b/helm-charts/sep-service/templates/sepserver-deployment.yaml @@ -119,6 +119,16 @@ spec: name: {{ .Values.fullName }}-secrets key: SEP45_JWT_SECRET optional: true + - name: SECRET_CALLBACK_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: CALLBACK_API_AUTH_SECRET + - name: SECRET_PLATFORM_API_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.fullName }}-secrets + key: PLATFORM_API_AUTH_SECRET resources: requests: memory: {{ .Values.services.sep.deployment.resources.requests.memory }} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt index 7db355c1e..2fb1d2294 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt @@ -55,8 +55,17 @@ class CustomerService( customer } request.id != null -> { - customerRepository.get(request.id) - ?: throw NotFoundException("customer for 'id' '${request.id}' not found", request.id) + val customer = + customerRepository.get(request.id) + ?: throw NotFoundException("customer for 'id' '${request.id}' not found", request.id) + assertCustomerMatchesRequest( + customer, + request.id, + request.account, + request.memo, + request.memoType + ) + customer } request.account != null -> { customerRepository.get(request.account, request.memo, request.memoType) @@ -80,7 +89,19 @@ class CustomerService( request.transactionId != null -> { getCustomerFromTransaction(request.transactionId, request.type) } - request.id != null -> customerRepository.get(request.id) + request.id != null -> { + val customer = customerRepository.get(request.id) + if (customer != null) { + assertCustomerMatchesRequest( + customer, + request.id, + request.account, + request.memo, + request.memoType + ) + } + customer + } request.account != null -> customerRepository.get(request.account, request.memo, request.memoType) else -> { @@ -251,6 +272,24 @@ class CustomerService( } } + private fun assertCustomerMatchesRequest( + customer: Customer, + requestId: String, + requestAccount: String?, + requestMemo: String?, + requestMemoType: String?, + ) { + if (requestAccount != null && requestAccount != customer.stellarAccount) { + throw NotFoundException("customer for 'id' '$requestId' not found", requestId) + } + if (requestMemo != null && requestMemo != customer.memo) { + throw NotFoundException("customer for 'id' '$requestId' not found", requestId) + } + if (requestMemoType != null && requestMemoType != customer.memoType) { + throw NotFoundException("customer for 'id' '$requestId' not found", requestId) + } + } + private fun convertCustomerToResponse( customer: Customer, type: String?, diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AuthHeaderUtil.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AuthHeaderUtil.kt new file mode 100644 index 000000000..28ae33af3 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AuthHeaderUtil.kt @@ -0,0 +1,19 @@ +package org.stellar.reference.client + +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.http.HttpHeaders +import org.stellar.reference.data.AuthSettings + +object AuthHeaderUtil { + fun addAuthHeaderIfNeeded(builder: HttpRequestBuilder, authSettings: AuthSettings) { + if (authSettings.type != AuthSettings.Type.JWT) { + return + } + val token = + JwtTokenProvider.createJwt( + authSettings.anchorToPlatformSecret, + authSettings.expirationMilliseconds, + ) + builder.headers.append(HttpHeaders.Authorization, "Bearer $token") + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/JwtTokenProvider.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/JwtTokenProvider.kt new file mode 100644 index 000000000..96e313603 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/JwtTokenProvider.kt @@ -0,0 +1,33 @@ +package org.stellar.reference.client + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import java.nio.charset.StandardCharsets +import java.util.Date +import org.stellar.anchor.util.GsonUtils + +object JwtTokenProvider { + fun createJwt(secret: String, expirationMilliseconds: Long): String { + val issuedAt = Date() + val expiresAt = Date(issuedAt.time + expirationMilliseconds) + val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8)) + return Jwts.builder() + .json(GsonJwtSerializer()) + .issuedAt(issuedAt) + .expiration(expiresAt) + .signWith(key, Jwts.SIG.HS256) + .compact() + } +} + +private class GsonJwtSerializer : io.jsonwebtoken.io.Serializer> { + override fun serialize(t: Map?): ByteArray { + val json = GsonUtils.getInstance().toJson(t ?: emptyMap()) + return json.toByteArray(StandardCharsets.UTF_8) + } + + override fun serialize(t: Map?, out: java.io.OutputStream) { + val json = GsonUtils.getInstance().toJson(t ?: emptyMap()) + out.write(json.toByteArray(StandardCharsets.UTF_8)) + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt index 45c343486..987fe09df 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt @@ -12,10 +12,19 @@ import org.stellar.anchor.api.platform.PatchTransactionsRequest import org.stellar.anchor.api.platform.PatchTransactionsResponse import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.util.GsonUtils +import org.stellar.reference.data.AuthSettings -class PlatformClient(private val httpClient: HttpClient, private val endpoint: String) { +class PlatformClient( + private val httpClient: HttpClient, + private val endpoint: String, + private val authSettings: AuthSettings, +) { suspend fun getTransaction(id: String): GetTransactionResponse { - val response = httpClient.request("$endpoint/transactions/$id") { method = HttpMethod.Get } + val response = + httpClient.request("$endpoint/transactions/$id") { + method = HttpMethod.Get + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) + } if (response.status != HttpStatusCode.OK) { throw Exception("Error getting transaction: ${response.status}") } @@ -29,6 +38,7 @@ class PlatformClient(private val httpClient: HttpClient, private val endpoint: S method = HttpMethod.Patch setBody(GsonUtils.getInstance().toJson(request)) contentType(ContentType.Application.Json) + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) } if (response.status != HttpStatusCode.OK) { throw Exception("Error patching transaction: ${response.status}") @@ -41,6 +51,7 @@ class PlatformClient(private val httpClient: HttpClient, private val endpoint: S val response = httpClient.request("$endpoint/transactions") { method = HttpMethod.Get + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) url { parameters.append("sep", request.sep.name.toLowerCasePreservingASCIIRules()) if (request.orderBy != null) { diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt index d8a04162f..102ff9c55 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt @@ -57,7 +57,7 @@ object ReferenceServerContainer { ServiceContainer.withdrawalService, config.sep24.interactiveJwtKey ) - event(ServiceContainer.eventService) + event(ServiceContainer.eventService, config.appSettings.isTest) customer(ServiceContainer.customerService) rate(ServiceContainer.rateService) sep24Interactive() diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt index 9baa59e2d..631c4cb38 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt @@ -52,5 +52,6 @@ object ServiceContainer { } }, config.appSettings.platformApiEndpoint, + config.authSettings, ) } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventRoute.kt index 932d34955..4e3ef26b2 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventRoute.kt @@ -3,6 +3,7 @@ package org.stellar.reference.event import com.google.gson.Gson import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.auth.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -10,38 +11,45 @@ import org.apache.hc.core5.http.HttpStatus import org.stellar.anchor.api.callback.SendEventResponse import org.stellar.anchor.util.GsonUtils import org.stellar.reference.data.SendEventRequest +import org.stellar.reference.di.AUTH_CONFIG_ENDPOINT -fun Route.event(eventService: EventService) { +fun Route.event(eventService: EventService, enableTestEndpoints: Boolean) { val gson: Gson = GsonUtils.getInstance() - route("/event") { - // The `POST /event` endpoint of the Callback API to receive an event. - post { - val receivedEventJson = call.receive() - val receivedEvent = gson.fromJson(receivedEventJson, SendEventRequest::class.java) - eventService.processEvent(receivedEvent) - call.respond(gson.toJson(SendEventResponse(HttpStatus.SC_OK, "event processed"))) - } - } - route("/events") { - // Test endpoint to get the events recorded by the reference server. - // The `txnId` parameter is optional. If it is provided, only the events with the given `txnId` - // will be returned. - get { call.respond(gson.toJson(eventService.getEvents(call.parameters["txnId"]))) } - // Test endpoint to clear the events recorded by the reference server. - delete { - eventService.clearEvents() - call.respond("Events cleared") + authenticate(AUTH_CONFIG_ENDPOINT) { + route("/event") { + // The `POST /event` endpoint of the Callback API to receive an event. + post { + val receivedEventJson = call.receive() + val receivedEvent = gson.fromJson(receivedEventJson, SendEventRequest::class.java) + eventService.processEvent(receivedEvent) + call.respond(gson.toJson(SendEventResponse(HttpStatus.SC_OK, "event processed"))) + } } - } - route("/events/latest") { - // Test endpoint to get the latest event recorded by the reference server. - get { - val latestEvent = eventService.getLatestEvent() - if (latestEvent != null) { - call.respond(gson.toJson(latestEvent)) - } else { - call.respond(HttpStatusCode.NotFound) + + if (enableTestEndpoints) { + route("/events") { + // Test endpoint to get the events recorded by the reference server. + // The `txnId` parameter is optional. If it is provided, only the events with the given + // `txnId` + // will be returned. + get { call.respond(gson.toJson(eventService.getEvents(call.parameters["txnId"]))) } + // Test endpoint to clear the events recorded by the reference server. + delete { + eventService.clearEvents() + call.respond("Events cleared") + } + } + route("/events/latest") { + // Test endpoint to get the latest event recorded by the reference server. + get { + val latestEvent = eventService.getLatestEvent() + if (latestEvent != null) { + call.respond(gson.toJson(latestEvent)) + } else { + call.respond(HttpStatusCode.NotFound) + } + } } } } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt index 401b1a0d1..b10ae22ea 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt @@ -100,7 +100,9 @@ class Sep6EventProcessor( when (val status = transaction.status) { PENDING_ANCHOR -> { val customer = transaction.customers.sender - if (verifyKyc(customer.account, customer.memo, transaction.kind).isNotEmpty()) { + if ( + verifyKyc(transaction.id, customer.account, customer.memo, transaction.kind).isNotEmpty() + ) { requestKyc(event) return } @@ -160,6 +162,9 @@ class Sep6EventProcessor( message = "Funds received from user", ), ) + PENDING_CUSTOMER_INFO_UPDATE -> { + requestCustomerFunds(transaction) + } COMPLETED -> { log.info { "Transaction ${transaction.id} completed" } } @@ -174,7 +179,9 @@ class Sep6EventProcessor( when (val status = transaction.status) { PENDING_ANCHOR -> { val customer = transaction.customers.sender - if (verifyKyc(customer.account, customer.memo, Kind.WITHDRAWAL).isNotEmpty()) { + if ( + verifyKyc(transaction.id, customer.account, customer.memo, Kind.WITHDRAWAL).isNotEmpty() + ) { requestKyc(event) return } @@ -222,6 +229,9 @@ class Sep6EventProcessor( ), ) } + PENDING_CUSTOMER_INFO_UPDATE -> { + requestCustomerFunds(transaction) + } COMPLETED -> { log.info { "Transaction ${transaction.id} completed" } } @@ -250,7 +260,9 @@ class Sep6EventProcessor( when (transaction.kind) { Kind.DEPOSIT -> { val sourceAsset = "iso4217:USD" - if (verifyKyc(customer.account, customer.memo, transaction.kind).isEmpty()) { + if ( + verifyKyc(transaction.id, customer.account, customer.memo, transaction.kind).isEmpty() + ) { runBlocking { // In deposit flow, If amount is specified, anchor can request that amount; // amount is either provided at transaction initialization or updated during KYC. @@ -288,7 +300,9 @@ class Sep6EventProcessor( sourceAsset] ?: throw RuntimeException("Unsupported asset: $sourceAsset") - if (verifyKyc(customer.account, customer.memo, transaction.kind).isEmpty()) { + if ( + verifyKyc(transaction.id, customer.account, customer.memo, transaction.kind).isEmpty() + ) { runBlocking { // In deposit-exchange flow, amount, sourceAsset and destinationAsset are always // specified. @@ -328,7 +342,9 @@ class Sep6EventProcessor( } Kind.WITHDRAWAL -> { val destinationAsset = "iso4217:USD" - if (verifyKyc(customer.account, customer.memo, transaction.kind).isEmpty()) { + if ( + verifyKyc(transaction.id, customer.account, customer.memo, transaction.kind).isEmpty() + ) { runBlocking { sepHelper.rpcAction( RpcMethod.REQUEST_ONCHAIN_FUNDS.toString(), @@ -353,7 +369,9 @@ class Sep6EventProcessor( } Kind.WITHDRAWAL_EXCHANGE -> { val destinationAsset = transaction.amountOut.asset - if (verifyKyc(customer.account, customer.memo, transaction.kind).isEmpty()) { + if ( + verifyKyc(transaction.id, customer.account, customer.memo, transaction.kind).isEmpty() + ) { runBlocking { // The amount was specified at transaction initialization sepHelper.rpcAction( @@ -393,6 +411,7 @@ class Sep6EventProcessor( } private fun verifyKyc( + transactionId: String, webAuthAccount: String, webAuthAccountMemo: String?, kind: Kind, @@ -400,6 +419,7 @@ class Sep6EventProcessor( val customer = runBlocking { customerService.getCustomer( GetCustomerRequest.builder() + .transactionId(transactionId) .account(webAuthAccount) .memo(webAuthAccountMemo) .memoType(if (webAuthAccountMemo != null) "id" else null) @@ -418,7 +438,8 @@ class Sep6EventProcessor( private fun requestKyc(event: SendEventRequest) { val kind = event.payload.transaction!!.kind val customer = event.payload.transaction.customers.sender - val missingFields = verifyKyc(customer.account, customer.memo, kind) + val missingFields = + verifyKyc(event.payload.transaction.id, customer.account, customer.memo, kind) runBlocking { if (missingFields.isNotEmpty()) { customerService.requestAdditionalFieldsForTransaction( @@ -441,6 +462,7 @@ class Sep6EventProcessor( customerService .upsertCustomer( PutCustomerRequest.builder() + .transactionId(event.payload.transaction.id) .account(customer.account) .memo(customer.memo) .memoType(memoType) diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/service/SepHelper.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/service/SepHelper.kt index 0750c0442..eada843a6 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/service/SepHelper.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/service/SepHelper.kt @@ -15,6 +15,7 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.serialization.json.Json import org.stellar.anchor.util.GsonUtils +import org.stellar.reference.client.AuthHeaderUtil import org.stellar.reference.data.* class SepHelper(cfg: Config) { @@ -26,12 +27,14 @@ class SepHelper(cfg: Config) { } val baseUrl = cfg.appSettings.platformApiEndpoint + private val authSettings = cfg.authSettings internal suspend fun patchTransaction(patchRecord: PatchTransactionTransaction) { val resp = client.patch("$baseUrl/transactions") { contentType(ContentType.Application.Json) setBody(PatchTransactionsRequest(listOf(PatchTransactionRecord(patchRecord)))) + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) } if (resp.status != HttpStatusCode.OK) { @@ -48,6 +51,7 @@ class SepHelper(cfg: Config) { client.post(baseUrl) { contentType(ContentType.Application.Json) setBody(listOf(RpcRequest(UUID.randomUUID().toString(), "2.0", method, params))) + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) } val respBody = resp.bodyAsText() @@ -71,7 +75,11 @@ class SepHelper(cfg: Config) { } internal suspend fun getTransaction(transactionId: String): Transaction { - return client.get("$baseUrl/transactions/$transactionId").body() + return client + .get("$baseUrl/transactions/$transactionId") { + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) + } + .body() } internal suspend fun sendCustodyStellarTransaction(transactionId: String) { @@ -79,6 +87,7 @@ class SepHelper(cfg: Config) { client.post("$baseUrl/transactions/$transactionId/payments") { contentType(ContentType.Application.Json) setBody("{}") + AuthHeaderUtil.addAuthHeaderIfNeeded(this, authSettings) } if (resp.status != HttpStatusCode.OK) { diff --git a/service-runner/src/main/resources/version-info.properties b/service-runner/src/main/resources/version-info.properties index 39f8ded68..f9d228034 100644 --- a/service-runner/src/main/resources/version-info.properties +++ b/service-runner/src/main/resources/version-info.properties @@ -1 +1 @@ -version=4.1.6 \ No newline at end of file +version=4.1.7