diff options
author | Antoine A <> | 2024-04-29 17:25:43 +0900 |
---|---|---|
committer | Antoine A <> | 2024-04-29 17:25:43 +0900 |
commit | e8490074b3d174acb7863a36c3529792c83158e9 (patch) | |
tree | 8a9cc51b9f3289454a9e63b30b1114bf71f7d63c /nexus | |
parent | 6aaf661c073d5ffd74f4407fd386f7df4d0702de (diff) | |
download | libeufin-e8490074b3d174acb7863a36c3529792c83158e9.tar.gz libeufin-e8490074b3d174acb7863a36c3529792c83158e9.tar.bz2 libeufin-e8490074b3d174acb7863a36c3529792c83158e9.zip |
nexus: wire gateway auth
Diffstat (limited to 'nexus')
-rw-r--r-- | nexus/conf/mini.conf | 15 | ||||
-rw-r--r-- | nexus/conf/test.conf | 7 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 32 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 2 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt | 65 | ||||
-rw-r--r-- | nexus/src/test/kotlin/WireGatewayApiTest.kt | 45 | ||||
-rw-r--r-- | nexus/src/test/kotlin/helpers.kt | 43 | ||||
-rw-r--r-- | nexus/src/test/kotlin/routines.kt | 146 |
8 files changed, 219 insertions, 136 deletions
diff --git a/nexus/conf/mini.conf b/nexus/conf/mini.conf new file mode 100644 index 00000000..1b52e17f --- /dev/null +++ b/nexus/conf/mini.conf @@ -0,0 +1,15 @@ +[nexus-ebics] +CURRENCY = CHF +BANK_DIALECT = postfinance +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json +IBAN = CH7789144474425692816 +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 +BIC = BIC +NAME = myname + +[libeufin-nexusdb-postgres] +CONFIG = postgres:///libeufincheck
\ No newline at end of file diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf index e6d52fff..dd7f3196 100644 --- a/nexus/conf/test.conf +++ b/nexus/conf/test.conf @@ -15,4 +15,9 @@ NAME = myname CONFIG = postgres:///libeufincheck [nexus-fetch] -IGNORE_TRANSACTIONS_BEFORE = 2024-04-04
\ No newline at end of file +IGNORE_TRANSACTIONS_BEFORE = 2024-04-04 + +[nexus-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = token +AUTH_TOKEN = secret-token
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt index eb1bac91..95cc17af 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -31,6 +31,10 @@ class NexusFetchConfig(config: TalerConfig) { val ignoreBefore = config.lookupDate("nexus-fetch", "ignore_transactions_before") } +class ApiConfig(config: TalerConfig, section: String) { + val authMethod = config.requireAuthMethod(section) +} + /** Configuration for libeufin-nexus */ class NexusConfig(val config: TalerConfig) { private fun requireString(option: String): String = config.requireString("nexus-ebics", option) @@ -65,6 +69,9 @@ class NexusConfig(val config: TalerConfig) { "gls" -> Dialect.gls else -> throw TalerConfigError.invalid("dialct", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'") } + + val wireGatewayApiCfg = config.apiConf("nexus-httpd-wire-gateway-api") + val revenueApiCfg = config.apiConf("nexus-httpd-revenue-api") } fun NexusConfig.checkCurrency(amount: TalerAmount) { @@ -72,4 +79,29 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) { "Wrong currency: expected regional $currency got ${amount.currency}", TalerErrorCode.GENERIC_CURRENCY_MISMATCH ) +} + +fun TalerConfig.requireAuthMethod(section: String): AuthMethod { + return when (val method = requireString(section, "auth_method", "auth method")) { + "none" -> AuthMethod.None + "token" -> { + val token = requireString(section, "auth_token") + AuthMethod.Basic(token) + } + else -> throw TalerConfigError.invalid("auth method target type", section, "auth_method", "expected 'token' or 'none' got '$method'") + } +} + +fun TalerConfig.apiConf(section: String): ApiConfig? { + val enabled = requireBoolean(section, "enabled") + return if (enabled) { + return ApiConfig(this, section) + } else { + null + } +} + +sealed interface AuthMethod { + data object None: AuthMethod + data class Basic(val token: String): AuthMethod }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt index 6073072b..185f3b0c 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -34,7 +34,7 @@ import tech.libeufin.nexus.db.ExchangeDAO.* import java.time.Instant -fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { +fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = authApi(cfg.wireGatewayApiCfg) { get("/taler-wire-gateway/config") { call.respond(WireGatewayConfig( currency = cfg.currency diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt new file mode 100644 index 00000000..f7de32eb --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt @@ -0,0 +1,65 @@ +/* + * 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.nexus.api + +import tech.libeufin.nexus.* +import tech.libeufin.common.* +import tech.libeufin.common.api.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* + +/** Apply api configuration for a route: conditional access and authentication */ +fun Route.authApi(cfg: ApiConfig?, callback: Route.() -> Unit): Route = + intercept(callback) { + if (cfg == null) { + throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) + } + val header = context.request.headers["Authorization"] + // Basic auth challenge + when (cfg.authMethod) { + AuthMethod.None -> {} + is AuthMethod.Basic -> { + if (header == null) { + //response.header(HttpHeaders.WWWAuthenticate, "Basic") ? + throw unauthorized( + "Authorization header not found", + TalerErrorCode.GENERIC_PARAMETER_MISSING + ) + } + val (scheme, content) = header.splitOnce(" ") ?: throw badRequest( + "Authorization is invalid", + TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED + ) + when (scheme) { + "Basic", "Bearer" -> { + // TODO choose between one of those + if (content != cfg.authMethod.token) { + throw unauthorized("Unknown token") + } + } + else -> throw unauthorized("Authorization method wrong or not supported") + } + } + } + }
\ No newline at end of file diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt index 2b662e83..224cd513 100644 --- a/nexus/src/test/kotlin/WireGatewayApiTest.kt +++ b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -30,9 +30,9 @@ class WireGatewayApiTest { // GET /accounts/{USERNAME}/taler-wire-gateway/config @Test fun config() = serverSetup { _ -> - //authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/config") + authRoutine(HttpMethod.Get, "/taler-wire-gateway/config") - client.get("/taler-wire-gateway/config").assertOk() + client.getA("/taler-wire-gateway/config").assertOk() } // Testing the POST /transfer call from the TWG API. @@ -46,20 +46,20 @@ class WireGatewayApiTest { "credit_account" to grothoffPayto } - //authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req) + authRoutine(HttpMethod.Post, "/taler-wire-gateway/transfer") // Check OK - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() // check idempotency - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() // Trigger conflict due to reused request_uid - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to ShortHashCode.rand() "exchange_base_url" to "http://different-exchange.example.com/" @@ -67,35 +67,35 @@ class WireGatewayApiTest { }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) // Currency mismatch - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Bad BASE32 wtid - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len wtid - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to Base32Crockford.encode(ByteArray(31).rand()) } }.assertBadRequest() // Bad BASE32 request_uid - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "request_uid" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len wtid - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "request_uid" to Base32Crockford.encode(ByteArray(65).rand()) } @@ -107,13 +107,13 @@ class WireGatewayApiTest { */ @Test fun historyIncoming() = serverSetup { db -> - //authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming") + authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming") historyRoutine<IncomingHistory>( url = "/taler-wire-gateway/history/incoming", ids = { it.incoming_transactions.map { it.row_id } }, registered = listOf( { - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json { "amount" to "CHF:12" "reserve_pub" to EddsaPublicKey.rand() @@ -144,7 +144,7 @@ class WireGatewayApiTest { */ @Test fun historyOutgoing() = serverSetup { db -> - //authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/outgoing") + authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing") historyRoutine<OutgoingHistory>( url = "/taler-wire-gateway/history/outgoing", ids = { it.outgoing_transactions.map { it.row_id } }, @@ -182,35 +182,40 @@ class WireGatewayApiTest { "debit_account" to grothoffPayto } - //authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true) + authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/add-incoming") // Check OK - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) }.assertOk() // Trigger conflict due to reused reserve_pub - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) // Currency mismatch - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Bad BASE32 reserve_pub - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) { "reserve_pub" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len reserve_pub - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) { "reserve_pub" to Base32Crockford.encode(ByteArray(31).rand()) } }.assertBadRequest() } + + @Test + fun noApi() = serverSetup("mini.conf") { _ -> + client.get("/taler-wire-gateway/config").assertNotImplemented() + } }
\ No newline at end of file diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt index dc5a98a8..2303a1ea 100644 --- a/nexus/src/test/kotlin/helpers.kt +++ b/nexus/src/test/kotlin/helpers.kt @@ -57,7 +57,7 @@ fun setup( fun serverSetup( conf: String = "test.conf", lambda: suspend ApplicationTestBuilder.(Database) -> Unit -) = setup { db, cfg -> +) = setup(conf) { db, cfg -> testApplication { application { nexusApi(db, cfg) @@ -129,7 +129,7 @@ fun genOutPay(subject: String, messageId: String? = null): OutgoingPayment { /** Perform a taler outgoing transaction */ suspend fun ApplicationTestBuilder.transfer() { - client.post("/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json { "request_uid" to HashCode.rand() "amount" to "CHF:55" @@ -150,4 +150,43 @@ suspend fun talerableOut(db: Database) { suspend fun talerableIn(db: Database) { val reserve_pub = ShortHashCode.rand() ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub")) +} + + +/* ----- Auth ----- */ + +/** Auto auth get request */ +suspend inline fun HttpClient.getA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return get(url) { + auth() + builder(this) + } +} + +/** Auto auth post request */ +suspend inline fun HttpClient.postA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return post(url) { + auth() + builder(this) + } +} + +/** Auto auth patch request */ +suspend inline fun HttpClient.patchA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return patch(url) { + auth() + builder(this) + } +} + +/** Auto auth delete request */ +suspend inline fun HttpClient.deleteA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return delete(url) { + auth() + builder(this) + } +} + +fun HttpRequestBuilder.auth() { + headers["Authorization"] = "Bearer secret-token" }
\ No newline at end of file diff --git a/nexus/src/test/kotlin/routines.kt b/nexus/src/test/kotlin/routines.kt index 9503565e..7b92dea7 100644 --- a/nexus/src/test/kotlin/routines.kt +++ b/nexus/src/test/kotlin/routines.kt @@ -29,123 +29,45 @@ import tech.libeufin.common.* import tech.libeufin.common.test.* import kotlin.test.assertEquals -suspend inline fun <reified B> ApplicationTestBuilder.historyRoutine( - url: String, - crossinline ids: (B) -> List<Long>, - registered: List<suspend () -> Unit>, - ignored: List<suspend () -> Unit> = listOf(), - polling: Boolean = true, - auth: String? = null -) { - // Get history - val history: suspend (String) -> HttpResponse = { params: String -> - client.get("$url?$params") { - //pwAuth(auth) - } - } - // Check history is following specs - val assertHistory: suspend HttpResponse.(Int) -> Unit = { size: Int -> - assertHistoryIds<B>(size, ids) - } - // Get latest registered id - val latestId: suspend () -> Long = { - history("delta=-1").assertOkJson<B>().run { ids(this)[0] } - } - // Check error when no transactions - history("delta=7").assertNoContent() - - // Run interleaved registered and ignore transactions - val registered_iter = registered.iterator() - val ignored_iter = ignored.iterator() - while (registered_iter.hasNext() || ignored_iter.hasNext()) { - if (registered_iter.hasNext()) registered_iter.next()() - if (ignored_iter.hasNext()) ignored_iter.next()() - } +// Test endpoint is correctly authenticated +suspend fun ApplicationTestBuilder.authRoutine( + method: HttpMethod, + path: String +) { + // No header + client.request(path) { + this.method = method + }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING) + // Bad header + client.request(path) { + this.method = method + headers["Authorization"] = "WTF" + }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) - val nbRegistered = registered.size - val nbIgnored = ignored.size - val nbTotal = nbRegistered + nbIgnored + // Bad token + client.request(path) { + this.method = method + headers["Authorization"] = "Bearer bad-token" + }.assertUnauthorized() - // Check ignored - history("delta=$nbTotal").assertHistory(nbRegistered) - // Check skip ignored - history("delta=$nbRegistered").assertHistory(nbRegistered) + // GLS deployment + // - testing did work ? + // token - basic bearer + // libeufin-nexus + // - wire gateway try camt.052 files +} - if (polling) { - // Check no polling when we cannot have more transactions - assertTime(0, 100) { - history("delta=-${nbRegistered+1}&long_poll_ms=1000") - .assertHistory(nbRegistered) - } - // Check no polling when already find transactions even if less than delta - assertTime(0, 100) { - history("delta=${nbRegistered+1}&long_poll_ms=1000") - .assertHistory(nbRegistered) - } - // Check polling - coroutineScope { - val id = latestId() - launch { // Check polling succeed - assertTime(100, 200) { - history("delta=2&start=$id&long_poll_ms=1000") - .assertHistory(1) - } - } - launch { // Check polling timeout - assertTime(200, 300) { - history("delta=1&start=${id+nbTotal*3}&long_poll_ms=200") - .assertNoContent() - } - } - delay(100) - registered[0]() - } - - // Test triggers - for (register in registered) { - coroutineScope { - val id = latestId() - launch { - assertTime(100, 200) { - history("delta=7&start=$id&long_poll_ms=1000") - .assertHistory(1) - } - } - delay(100) - register() - } - } - - // Test doesn't trigger - coroutineScope { - val id = latestId() - launch { - assertTime(200, 300) { - history("delta=7&start=$id&long_poll_ms=200") - .assertNoContent() - } - } - delay(100) - for (ignore in ignored) { - ignore() - } - } - } - - // Testing ranges. - repeat(20) { - registered[0]() +suspend inline fun <reified B> ApplicationTestBuilder.historyRoutine( + url: String, + crossinline ids: (B) -> List<Long>, + registered: List<suspend () -> Unit>, + ignored: List<suspend () -> Unit> = listOf(), + polling: Boolean = true +) { + abstractHistoryRoutine(ids, registered, ignored, polling) { params: String -> + client.getA("$url?$params") } - val id = latestId() - // Default - history("").assertHistory(20) - // forward range: - history("delta=10").assertHistory(10) - history("delta=10&start=4").assertHistory(10) - // backward range: - history("delta=-10").assertHistory(10) - history("delta=-10&start=${id-4}").assertHistory(10) }
\ No newline at end of file |