libeufin

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

commit 8e85df18b90c6f0077e5f66d2de9bd63df41476a
parent ba9fa676fe90514c39e52fb20452ce8995e2dab7
Author: Antoine A <>
Date:   Thu,  5 Dec 2024 17:13:40 +0100

nexus: make config endpoints unauth and improve auth config to match exchange

Diffstat:
Mcontrib/nexus.conf | 30++++++++++++++++++++++++++----
Anexus/conf/auth.conf | 27+++++++++++++++++++++++++++
Mnexus/conf/test.conf | 10+++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 11+++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt | 18++++++++++--------
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 198++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt | 63++++++++++++++++++++++++++++++++++-----------------------------
Mnexus/src/test/kotlin/RevenueApiTest.kt | 10+++++++---
Mnexus/src/test/kotlin/WireGatewayApiTest.kt | 12+++++++++---
Mnexus/src/test/kotlin/routines.kt | 34+++++++++++++++++++++++++++-------
10 files changed, 256 insertions(+), 157 deletions(-)

diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -87,11 +87,33 @@ BIND_TO = 0.0.0.0 # UNIXPATH_MODE = 660 [nexus-httpd-wire-gateway-api] +# Whether to serve the Wire Gateway API ENABLED = NO -AUTH_METHOD = bearer-token -AUTH_BEARER_TOKEN = + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = [nexus-httpd-revenue-api] +# Whether to serve the Revenue API ENABLED = NO -AUTH_METHOD = bearer-token -AUTH_BEARER_TOKEN = + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = diff --git a/nexus/conf/auth.conf b/nexus/conf/auth.conf @@ -0,0 +1,26 @@ +[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 + +[nexus-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = basic +USERNAME = username +PASSWORD = password + +[nexus-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = bearer-token +AUTH_BEARER_TOKEN = secret-token +\ No newline at end of file diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf @@ -16,10 +16,10 @@ CONFIG = postgres:///libeufincheck [nexus-httpd-wire-gateway-api] ENABLED = YES -AUTH_METHOD = bearer-token -AUTH_BEARER_TOKEN = secret-token +AUTH_METHOD = bearer +TOKEN = secret-token [nexus-httpd-revenue-api] ENABLED = YES -AUTH_METHOD = bearer-token -AUTH_BEARER_TOKEN = secret-token -\ No newline at end of file +AUTH_METHOD = bearer +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 @@ -136,8 +136,18 @@ private fun TalerConfigSection.requireAuthMethod(): AuthMethod { return mapLambda("auth_method", "auth method", mapOf( "none" to { AuthMethod.None }, "bearer-token" to { + logger.warn("Deprecated auth method option 'auth_method' used deprecated value 'bearer-token'") val token = string("auth_bearer_token").require() AuthMethod.Bearer(token) + }, + "bearer" to { + val token = string("token").require() + AuthMethod.Bearer(token) + }, + "basic" to { + val username = string("username").require() + val password = string("password").require() + AuthMethod.Basic("$username:$password".encodeBase64()) } )).require() } @@ -154,6 +164,7 @@ private fun TalerConfigSection.apiConf(): ApiConfig? { sealed interface AuthMethod { data object None: AuthMethod data class Bearer(val token: String): AuthMethod + data class Basic(val token: String): AuthMethod } enum class AccountType { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt @@ -28,20 +28,22 @@ import tech.libeufin.common.RevenueIncomingHistory import tech.libeufin.nexus.NexusConfig import tech.libeufin.nexus.db.Database -fun Routing.revenueApi(db: Database, cfg: NexusConfig) = authApi(cfg.revenueApiCfg) { +fun Routing.revenueApi(db: Database, cfg: NexusConfig) = conditional(cfg.revenueApiCfg) { get("/taler-revenue/config") { call.respond(RevenueConfig( currency = cfg.currency )) } - get("/taler-revenue/history") { - val params = HistoryParams.extract(call.request.queryParameters) - val items = db.payment.revenueHistory(params) + auth(cfg.revenueApiCfg) { + get("/taler-revenue/history") { + val params = HistoryParams.extract(call.request.queryParameters) + val items = db.payment.revenueHistory(params) - if (items.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(RevenueIncomingHistory(items, cfg.ebics.payto)) + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(RevenueIncomingHistory(items, cfg.ebics.payto)) + } } } } \ 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 @@ -37,117 +37,119 @@ import tech.libeufin.nexus.iso20022.* import java.time.Instant -fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = authApi(cfg.wireGatewayApiCfg) { +fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) { get("/taler-wire-gateway/config") { call.respond(WireGatewayConfig( currency = cfg.currency )) } - post("/taler-wire-gateway/transfer") { - val req = call.receive<TransferRequest>() - cfg.checkCurrency(req.amount) - req.credit_account.expectRequestIban() - val endToEndId = randEbicsId() - val res = db.exchange.transfer( - req, - endToEndId, - Instant.now() - ) - when (res) { - TransferResult.RequestUidReuse -> throw conflict( - "request_uid used already", - TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED + auth(cfg.wireGatewayApiCfg) { + post("/taler-wire-gateway/transfer") { + val req = call.receive<TransferRequest>() + cfg.checkCurrency(req.amount) + req.credit_account.expectRequestIban() + val endToEndId = randEbicsId() + val res = db.exchange.transfer( + req, + endToEndId, + Instant.now() ) - is TransferResult.Success -> call.respond( - TransferResponse( - timestamp = res.timestamp, - row_id = res.id + when (res) { + TransferResult.RequestUidReuse -> throw conflict( + "request_uid used already", + TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) - ) + is TransferResult.Success -> call.respond( + TransferResponse( + timestamp = res.timestamp, + row_id = res.id + ) + ) + } } - } - get("/taler-wire-gateway/transfers") { - val params = TransferParams.extract(call.request.queryParameters) - val items = db.exchange.pageTransfer(params) + get("/taler-wire-gateway/transfers") { + val params = TransferParams.extract(call.request.queryParameters) + val items = db.exchange.pageTransfer(params) - if (items.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(TransferList(items, cfg.ebics.payto)) - } - } - get("/taler-wire-gateway/transfers/{ROW_ID}") { - val id = call.longPath("ROW_ID") - val transfer = db.exchange.getTransfer(id) ?: throw notFound( - "Transfer '$id' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - call.respond(transfer) - } - suspend fun <T> ApplicationCall.historyEndpoint( - reduce: (List<T>, String) -> Any, - dbLambda: suspend ExchangeDAO.(HistoryParams) -> List<T> - ) { - val params = HistoryParams.extract(this.request.queryParameters) - val items = db.exchange.dbLambda(params) - if (items.isEmpty()) { - this.respond(HttpStatusCode.NoContent) - } else { - this.respond(reduce(items, cfg.ebics.payto)) + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(TransferList(items, cfg.ebics.payto)) + } } - } - get("/taler-wire-gateway/history/incoming") { - call.historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) - } - get("/taler-wire-gateway/history/outgoing") { - call.historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) - } - suspend fun ApplicationCall.addIncoming( - amount: TalerAmount, - debitAccount: Payto, - subject: String, - metadata: TalerIncomingMetadata - ) { - cfg.checkCurrency(amount) - val debitAccount = debitAccount.expectRequestIban() - val timestamp = Instant.now() - val bankId = randEbicsId() - val res = db.payment.registerTalerableIncoming(IncomingPayment( - amount = amount, - debtorPayto = debitAccount, - subject = subject, - executionTime = timestamp, - bankId = bankId - ), metadata) - when (res) { - IncomingRegistrationResult.ReservePubReuse -> throw conflict( - "reserve_pub used already", - TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + get("/taler-wire-gateway/transfers/{ROW_ID}") { + val id = call.longPath("ROW_ID") + val transfer = db.exchange.getTransfer(id) ?: throw notFound( + "Transfer '$id' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) - is IncomingRegistrationResult.Success -> respond( - AddIncomingResponse( - timestamp = TalerProtocolTimestamp(timestamp), - row_id = res.id + call.respond(transfer) + } + suspend fun <T> ApplicationCall.historyEndpoint( + reduce: (List<T>, String) -> Any, + dbLambda: suspend ExchangeDAO.(HistoryParams) -> List<T> + ) { + val params = HistoryParams.extract(this.request.queryParameters) + val items = db.exchange.dbLambda(params) + if (items.isEmpty()) { + this.respond(HttpStatusCode.NoContent) + } else { + this.respond(reduce(items, cfg.ebics.payto)) + } + } + get("/taler-wire-gateway/history/incoming") { + call.historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) + } + get("/taler-wire-gateway/history/outgoing") { + call.historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) + } + suspend fun ApplicationCall.addIncoming( + amount: TalerAmount, + debitAccount: Payto, + subject: String, + metadata: TalerIncomingMetadata + ) { + cfg.checkCurrency(amount) + val debitAccount = debitAccount.expectRequestIban() + val timestamp = Instant.now() + val bankId = randEbicsId() + val res = db.payment.registerTalerableIncoming(IncomingPayment( + amount = amount, + debtorPayto = debitAccount, + subject = subject, + executionTime = timestamp, + bankId = bankId + ), metadata) + when (res) { + IncomingRegistrationResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) + is IncomingRegistrationResult.Success -> respond( + AddIncomingResponse( + timestamp = TalerProtocolTimestamp(timestamp), + row_id = res.id + ) + ) + } + } + post("/taler-wire-gateway/admin/add-incoming") { + val req = call.receive<AddIncomingRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming ${req.reserve_pub}", + metadata = TalerIncomingMetadata(TalerIncomingType.reserve, req.reserve_pub) + ) + } + post("/taler-wire-gateway/admin/add-kycauth") { + val req = call.receive<AddKycauthRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming KYC:${req.account_pub}", + metadata = TalerIncomingMetadata(TalerIncomingType.kyc, req.account_pub) ) } - } - post("/taler-wire-gateway/admin/add-incoming") { - val req = call.receive<AddIncomingRequest>() - call.addIncoming( - amount = req.amount, - debitAccount = req.debit_account, - subject = "Manual incoming ${req.reserve_pub}", - metadata = TalerIncomingMetadata(TalerIncomingType.reserve, req.reserve_pub) - ) - } - post("/taler-wire-gateway/admin/add-kycauth") { - val req = call.receive<AddKycauthRequest>() - call.addIncoming( - amount = req.amount, - debitAccount = req.debit_account, - subject = "Manual incoming KYC:${req.account_pub}", - metadata = TalerIncomingMetadata(TalerIncomingType.kyc, req.account_pub) - ) } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt @@ -27,37 +27,42 @@ import tech.libeufin.common.api.intercept import tech.libeufin.nexus.ApiConfig import tech.libeufin.nexus.AuthMethod -/** Apply api configuration for a route: conditional access and authentication */ -fun Route.authApi(cfg: ApiConfig?, callback: Route.() -> Unit): Route = +/** Apply authentication api configuration for a route */ +fun Route.auth(cfg: ApiConfig?, callback: Route.() -> Unit): Route = intercept("Auth", callback) { - if (cfg == null) { - throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) - } - val header = this.request.headers[HttpHeaders.Authorization] - // Basic auth challenge - when (cfg.authMethod) { - AuthMethod.None -> {} - is AuthMethod.Bearer -> { - if (header == null) { - this.response.header(HttpHeaders.WWWAuthenticate, "Bearer") - 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 + if (cfg?.authMethod != AuthMethod.None) { + val header = this.request.headers[HttpHeaders.Authorization] + val (expectedScheme, token) = when (val method = cfg?.authMethod) { + is AuthMethod.Basic -> "Basic" to method.token + is AuthMethod.Bearer -> "Bearer" to method.token + else -> throw UnsupportedOperationException() + } + + if (header == null) { + this.response.header(HttpHeaders.WWWAuthenticate, expectedScheme) + throw unauthorized( + "Authorization header not found", + TalerErrorCode.GENERIC_PARAMETER_MISSING ) - when (scheme) { - "Bearer" -> { - // TODO choose between one of those - if (content != cfg.authMethod.token) { - throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN) - } - } - else -> throw unauthorized("Authorization method wrong or not supported") + } + val (scheme, content) = header.splitOnce(" ") ?: throw badRequest( + "Authorization is invalid", + TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED + ) + if (scheme == expectedScheme) { + if (content != token) { + throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN) } + } else { + throw unauthorized("Expected scheme $expectedScheme got '$scheme'") } - } + } + } + +/** Apply conditional api configuration for a route */ +fun Route.conditional(cfg: ApiConfig?, callback: Route.() -> Unit): Route = + intercept("Conditional", callback) { + if (cfg == null) { + throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) + } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/RevenueApiTest.kt b/nexus/src/test/kotlin/RevenueApiTest.kt @@ -18,6 +18,7 @@ */ import io.ktor.http.* +import io.ktor.client.request.* import org.junit.Test import tech.libeufin.common.RevenueIncomingHistory import tech.libeufin.common.assertNotImplemented @@ -27,9 +28,7 @@ class RevenueApiTest { // GET /taler-revenue/config @Test fun config() = serverSetup { - authRoutine(HttpMethod.Get, "/taler-revenue/config") - - client.getA("/taler-revenue/config").assertOk() + client.get("/taler-revenue/config").assertOk() } // GET /taler-revenue/history @@ -63,4 +62,9 @@ class RevenueApiTest { fun noApi() = serverSetup("mini.conf") { client.getA("/taler-revenue/config").assertNotImplemented() } + + @Test + fun auth() = serverSetup("auth.conf") { + authRoutine(HttpMethod.Get, "/taler-revenue/history") + } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -31,9 +31,7 @@ class WireGatewayApiTest { // GET /taler-wire-gateway/config @Test fun config() = serverSetup { - authRoutine(HttpMethod.Get, "/taler-wire-gateway/config") - - client.getA("/taler-wire-gateway/config").assertOk() + client.get("/taler-wire-gateway/config").assertOk() } // POST /taler-wire-gateway/transfer @@ -333,4 +331,12 @@ class WireGatewayApiTest { fun noApi() = serverSetup("mini.conf") { client.get("/taler-wire-gateway/config").assertNotImplemented() } + + @Test + fun auth() = serverSetup("auth.conf") { + authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false) + client.get("/taler-wire-gateway/history/incoming") { + basicAuth("username", "password") + }.assertNoContent() + } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/routines.kt b/nexus/src/test/kotlin/routines.kt @@ -25,11 +25,11 @@ import tech.libeufin.common.assertBadRequest import tech.libeufin.common.assertUnauthorized import tech.libeufin.common.test.abstractHistoryRoutine - // Test endpoint is correctly authenticated suspend fun ApplicationTestBuilder.authRoutine( - method: HttpMethod, - path: String + method: HttpMethod, + path: String, + token: Boolean = true ) { // No header client.request(path) { @@ -42,11 +42,31 @@ suspend fun ApplicationTestBuilder.authRoutine( headers[HttpHeaders.Authorization] = "WTF" }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) + // Wrong scheme + if (token) { + client.request(path) { + this.method = method + headers[HttpHeaders.Authorization] = "Basic bad-token" + }.assertUnauthorized(TalerErrorCode.GENERIC_UNAUTHORIZED) + } else { + client.request(path) { + this.method = method + headers[HttpHeaders.Authorization] = "Bearer bad-token" + }.assertUnauthorized(TalerErrorCode.GENERIC_UNAUTHORIZED) + } + // Bad token - client.request(path) { - this.method = method - headers[HttpHeaders.Authorization] = "Bearer bad-token" - }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) + if (token) { + client.request(path) { + this.method = method + headers[HttpHeaders.Authorization] = "Bearer bad-token" + }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) + } else { + client.request(path) { + this.method = method + basicAuth("username", "bad-password") + }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) + } }