libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 4ddcada57dc252c4ccdb79d573228e25bc2811ed
parent e3a62fa46e75cff879b59725165490943d0d0c1f
Author: Antoine A <>
Date:   Wed,  7 Feb 2024 19:02:27 +0100

Generate withdraw URI using request base url

Diffstat:
Mbank/build.gradle | 1+
Mbank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt | 27++++++++-------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 8--------
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 4+---
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 2++
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 37+++++++++++++++++--------------------
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 32++------------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 4+++-
Mbuild.gradle | 2+-
Dcommon/src/main/kotlin/HTTP.kt | 68--------------------------------------------------------------------
Mcontrib/bank.conf | 8--------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 2+-
12 files changed, 36 insertions(+), 159 deletions(-)

diff --git a/bank/build.gradle b/bank/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation("io.ktor:ktor-server-status-pages:$ktor_version") implementation("io.ktor:ktor-server-netty:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + implementation("io.ktor:ktor-server-forwarded-header:$ktor_version") // UNIX domain sockets support (used to connect to PostgreSQL) implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -49,24 +49,19 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { ) call.respond(op.copy( suggested_exchange = ctx.suggestedWithdrawalExchange, - confirm_transfer_url = ctx.spaCaptchaURL?.run { - getWithdrawalConfirmUrl( - baseUrl = this, - wopId = uuid - ) - } + confirm_transfer_url = if (op.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null )) } post("/taler-integration/withdrawal-operation/{wopid}") { - val opId = call.uuidParameter("wopid") + val uuid = call.uuidParameter("wopid") val req = call.receive<BankWithdrawalOperationPostRequest>() val res = db.withdrawal.setDetails( - opId, req.selected_exchange, req.reserve_pub + uuid, req.selected_exchange, req.reserve_pub ) when (res) { is WithdrawalSelectionResult.UnknownOperation -> throw notFound( - "Withdrawal operation $opId not found", + "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) is WithdrawalSelectionResult.AlreadySelected -> throw conflict( @@ -86,25 +81,19 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) is WithdrawalSelectionResult.Success -> { - val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && res.status == WithdrawalStatus.selected) { - getWithdrawalConfirmUrl( - baseUrl = ctx.spaCaptchaURL, - wopId = opId - ) - } else null call.respond(BankWithdrawalOperationPostResponse( transfer_done = res.status == WithdrawalStatus.confirmed, status = res.status, - confirm_transfer_url = confirmUrl + confirm_transfer_url = if (res.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null )) } } } post("/taler-integration/withdrawal-operation/{wopid}/abort") { - val opId = call.uuidParameter("wopid") - when (db.withdrawal.abort(opId)) { + val uuid = call.uuidParameter("wopid") + when (db.withdrawal.abort(uuid)) { AbortResult.UnknownOperation -> throw notFound( - "Withdrawal operation $opId not found", + "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) AbortResult.AlreadyConfirmed -> throw conflict( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -42,13 +42,6 @@ data class BankConfig( val defaultDebtLimit: TalerAmount, val registrationBonus: TalerAmount, val suggestedWithdrawalExchange: String?, - /** - * URL where the user should be redirected to complete the captcha. - * It can contain the substring "{woid}" that is going to be replaced - * with the withdrawal operation id and should point where the bank - * SPA is located. - */ - val spaCaptchaURL: String?, val allowConversion: Boolean, val fiatCurrency: String?, val fiatCurrencySpec: CurrencySpecification?, @@ -142,7 +135,6 @@ fun TalerConfig.loadBankConfig(): BankConfig { defaultDebtLimit = amount("libeufin-bank", "default_debt_limit", regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency), registrationBonus = amount("libeufin-bank", "registration_bonus", regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency), suggestedWithdrawalExchange = lookupString("libeufin-bank", "suggested_withdrawal_exchange"), - spaCaptchaURL = lookupString("libeufin-bank", "spa_captcha_url"), spaPath = lookupPath("libeufin-bank", "spa"), allowConversion = allowConversion, fiatCurrency = fiatCurrency, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -503,12 +503,10 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_UNALLOWED_DEBIT ) WithdrawalCreationResult.Success -> { - val bankBaseUrl = call.request.getBaseUrl() - ?: throw internalServerError("Bank could not find its own base URL") call.respond( BankAccountCreateWithdrawalResponse( withdrawal_id = opId.toString(), - taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) + taler_withdraw_uri = call.request.talerWithdrawUri(opId) ) ) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -36,6 +36,7 @@ import io.ktor.server.plugins.callloging.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.statuspages.* +import io.ktor.server.plugins.forwardedheaders.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -120,6 +121,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) { } } } + install(XForwardedHeaders) install(CORS) { anyHost() allowHeader(HttpHeaders.Authorization) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -59,32 +59,29 @@ suspend fun ApplicationCall.bankInfo(db: Database, ctx: BankPaytoCtx): BankInfo * * https://$BANK_URL/taler-integration */ -fun getTalerWithdrawUri(baseUrl: String, woId: String) = url { - val baseUrlObj = URI(baseUrl).toURL() +fun ApplicationRequest.talerWithdrawUri(id: UUID) = url { protocol = URLProtocol( - name = "taler".plus(if (baseUrlObj.protocol.lowercase() == "http") "+http" else ""), defaultPort = -1 + name = if (origin.scheme == "http") "taler+http" else "taler", + defaultPort = -1 ) host = "withdraw" - val pathSegments = mutableListOf( - // adds the hostname(+port) of the actual bank that will serve the withdrawal request. - baseUrlObj.host.plus( - if (baseUrlObj.port != -1) ":${baseUrlObj.port}" - else "" - ) - ) - // Removing potential double slashes. - baseUrlObj.path.split("/").forEach { - if (it.isNotEmpty()) pathSegments.add(it) + appendPathSegments(origin.serverHost) + headers["X-Forward-Prefix"]?.let { + appendPathSegments(it) } - pathSegments.add("taler-integration/${woId}") - this.appendPathSegments(pathSegments) + appendPathSegments("taler-integration", id.toString()) } -// Builds a withdrawal confirm URL. -fun getWithdrawalConfirmUrl( - baseUrl: String, wopId: UUID -): String { - return baseUrl.replace("{woid}", wopId.toString()) +fun ApplicationRequest.withdrawConfirmUrl(id: UUID) = url { + protocol = URLProtocol( + name = origin.scheme, + defaultPort = -1 + ) + host = origin.serverHost + headers["X-Forward-Prefix"]?.let { + appendPathSegments(it) + } + appendEncodedPathSegments("webui", "#", "operation", id.toString()) } fun ApplicationCall.uuidParameter(name: String): UUID { diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -99,12 +99,14 @@ class BankIntegrationApiTest { json(req) }.assertOkJson<BankWithdrawalOperationPostResponse> { assertEquals(WithdrawalStatus.selected, it.status) + assertEquals("http://localhost/webui/#/operation/$uuid", it.confirm_transfer_url) } // Check idempotence client.post("/taler-integration/withdrawal-operation/$uuid") { json(req) }.assertOkJson<BankWithdrawalOperationPostResponse> { assertEquals(WithdrawalStatus.selected, it.status) + assertEquals("http://localhost/webui/#/operation/$uuid", it.confirm_transfer_url) } // Check already selected client.post("/taler-integration/withdrawal-operation/$uuid") { @@ -188,34 +190,4 @@ class BankIntegrationApiTest { client.postA("/taler-integration/withdrawal-operation/${UUID.randomUUID()}/abort") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } - - // Testing the generation of taler://withdraw-URIs. - @Test - fun testWithdrawUri() { - // Checking the taler+http://-style. - val withHttp = getTalerWithdrawUri( - "http://example.com", - "my-id" - ) - assertEquals(withHttp, "taler+http://withdraw/example.com/taler-integration/my-id") - // Checking the taler://-style - val onlyTaler = getTalerWithdrawUri( - "https://example.com/", - "my-id" - ) - // Note: this tests as well that no double slashes belong to the result - assertEquals(onlyTaler, "taler://withdraw/example.com/taler-integration/my-id") - // Checking the removal of subsequent slashes - val manySlashes = getTalerWithdrawUri( - "https://www.example.com//////", - "my-id" - ) - assertEquals(manySlashes, "taler://withdraw/www.example.com/taler-integration/my-id") - // Checking with specified port number - val withPort = getTalerWithdrawUri( - "https://www.example.com:9876", - "my-id" - ) - assertEquals(withPort, "taler://withdraw/www.example.com:9876/taler-integration/my-id") - } } \ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -990,7 +990,9 @@ class CoreBankWithdrawalApiTest { // Check OK client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:9.0" } - }.assertOk() + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + assertEquals("taler+http://withdraw/localhost/taler-integration/${it.withdrawal_id}", it.taler_withdraw_uri) + } // Check exchange account client.postA("/accounts/exchange/withdrawals") { diff --git a/build.gradle b/build.gradle @@ -22,7 +22,7 @@ if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)){ allprojects { ext { set("kotlin_version", "1.9.22") - set("ktor_version", "2.3.7") + set("ktor_version", "2.3.8") set("clikt_version", "4.2.2") set("coroutines_version", "1.7.3") set("postgres_version", "42.7.1") diff --git a/common/src/main/kotlin/HTTP.kt b/common/src/main/kotlin/HTTP.kt @@ -1,67 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.common - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.util.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory - -private val logger: Logger = LoggerFactory.getLogger("libeufin-common") - -// Get the base URL of a request, returns null if any problem occurs. -fun ApplicationRequest.getBaseUrl(): String? { - return if (this.headers.contains("X-Forwarded-Host")) { - logger.info("Building X-Forwarded- base URL") - // FIXME: should tolerate a missing X-Forwarded-Prefix. - var prefix: String = this.headers["X-Forwarded-Prefix"] - ?: run { - logger.error("Reverse proxy did not define X-Forwarded-Prefix") - return null - } - if (!prefix.endsWith("/")) - prefix += "/" - URLBuilder( - protocol = URLProtocol( - name = this.headers["X-Forwarded-Proto"] ?: run { - logger.error("Reverse proxy did not define X-Forwarded-Proto") - return null - }, - defaultPort = -1 // Port must be specified with X-Forwarded-Host. - ), - host = this.headers["X-Forwarded-Host"] ?: run { - logger.error("Reverse proxy did not define X-Forwarded-Host") - return null - } - ).apply { - encodedPath = prefix - // Gets dropped otherwise. - if (!encodedPath.endsWith("/")) - encodedPath += "/" - }.buildString() - } else { - this.call.url { - parameters.clear() - encodedPath = "/" - } - } -} -\ No newline at end of file diff --git a/contrib/bank.conf b/contrib/bank.conf @@ -54,17 +54,9 @@ PORT = 8080 # What should be the file access permissions for UNIXPATH? Only used if SERVE is unix. # UNIXPATH_MODE = 660 - # Path to spa files SPA = $DATADIR/spa/ -# URL that wallets are redirected to when they need to confirm -# a withdrawal. -# The string {woid} is replaced with the withdrawal operation ID. -# FIXME: The name is not great. Maybe call it WITHDRAWAL_CONFIRMATION_REDIRECT -# or something similar? -#SPA_CAPTCHA_URL = https://bank.demo.taler.net/webui/#/operation/{woid} - # Exchange that is suggested to wallets when withdrawing. #SUGGESTED_WITHDRAWAL_EXCHANGE = https://exchange.demo.taler.net/ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -435,7 +435,7 @@ enum class Document { } } -class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 notifications") { +class EbicsFetch: CliktCommand("Fetches EBICS files") { private val common by CommonOption() private val transient by option( "--transient",