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:
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)
+ }
}