libeufin

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

commit 268cee47f231f2b6e785a78aa0be908b28458e7b
parent edee80cc2d3363c68ea7dc7c588545924d0eb522
Author: Antoine A <>
Date:   Wed, 30 Oct 2024 17:42:13 +0100

bank: add create-token cmd and clean token scope logic

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 14+++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 32++++++++++++++++----------------
Mbank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 27++++++++++++---------------
Abank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 2+-
10 files changed, 133 insertions(+), 41 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -71,6 +71,6 @@ data class RelativeTime( } companion object { - private const val MAX_SAFE_INTEGER = 9007199254740991L // 2^53 - 1 + const val MAX_SAFE_INTEGER = 9007199254740991L // 2^53 - 1 } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -289,8 +289,20 @@ enum class TanChannel { enum class TokenScope { readonly, readwrite, + revenue; + + fun logical(): TokenLogicalScope = when (this) { + readonly -> TokenLogicalScope.readonly + readwrite -> TokenLogicalScope.readwrite + revenue -> TokenLogicalScope.revenue + } +} + +enum class TokenLogicalScope { + readonly, + readwrite, revenue, - refreshable // Not spec'd as a scope! + refreshable } data class BearerToken( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -98,7 +98,7 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow } } } - authAdmin(db, ctx.pwCrypto, TokenScope.readwrite, ctx.basicAuthCompat) { + authAdmin(db, ctx.pwCrypto, TokenLogicalScope.readwrite, ctx.basicAuthCompat) { post("/conversion-info/conversion-rate") { val req = call.receive<ConversionRate>() for (regionalAmount in sequenceOf(req.cashin_fee, req.cashin_tiny_amount, req.cashout_min_amount)) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -70,7 +70,7 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { ) ) } - authAdmin(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat) { + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { get("/monitor") { val params = MonitorParams.extract(call.request.queryParameters) call.respond(db.monitor(params)) @@ -86,7 +86,7 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) - auth(db, cfg.pwCrypto, TokenScope.refreshable, cfg.basicAuthCompat, allowPw = true) { + auth(db, cfg.pwCrypto, TokenLogicalScope.refreshable, cfg.basicAuthCompat, allowPw = true) { post("/accounts/{USERNAME}/token") { val existingToken = call.authToken val req = call.receive<TokenRequest>() @@ -96,7 +96,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { val refreshingToken = db.token.access(existingToken, Instant.now()) ?: throw internalServerError( "Token used to auth not found in the database!" ) - if (!validScope(req.scope, refreshingToken.scope)) + if (!validScope(req.scope.logical(), refreshingToken.scope)) throw forbidden( "Impossible to refresh a token with a larger scope", TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT @@ -137,7 +137,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { ) } } - auth(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { delete("/accounts/{USERNAME}/token") { val token = call.authToken ?: throw badRequest("Basic auth not supported here.") db.token.delete(token) @@ -307,7 +307,7 @@ suspend fun patchAccount( } private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { - authAdmin(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat, !cfg.allowRegistration) { + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, !cfg.allowRegistration) { post("/accounts") { val req = call.receive<RegisterAccountRequest>() when (val result = createAccount(db, cfg, req, call.isAdmin)) { @@ -330,7 +330,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { auth( db, cfg.pwCrypto, - TokenScope.readwrite, + TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true, requireAdmin = !cfg.allowAccountDeletion @@ -361,7 +361,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } } } - auth(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) { patch("/accounts/{USERNAME}") { val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig) val res = patchAccount(db, cfg, req, call.username, call.isAdmin, challenge != null, challenge?.channel, challenge?.info) @@ -424,7 +424,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { call.respond(PublicAccountsResponse(publicAccounts)) } } - authAdmin(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat) { + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { get("/accounts") { val params = AccountParams.extract(call.request.queryParameters) val accounts = db.account.pageAdmin(params, cfg.payto) @@ -435,7 +435,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } } } - auth(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { get("/accounts/{USERNAME}") { val account = db.account.get(call.username, cfg.payto) ?: throw unknownAccount(call.username) call.respond(account) @@ -444,7 +444,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { get("/accounts/{USERNAME}/transactions") { val params = HistoryParams.extract(call.request.queryParameters) val bankAccount = call.bankInfo(db, cfg.payto) @@ -466,7 +466,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { call.respond(tx) } } - auth(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { post("/accounts/{USERNAME}/transactions") { val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction) @@ -520,7 +520,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { } private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { post("/accounts/{USERNAME}/withdrawals") { val req = call.receive<BankAccountCreateWithdrawalRequest>() req.amount?.run(cfg::checkRegionalCurrency) @@ -638,7 +638,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { } private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) { - auth(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { post("/accounts/{USERNAME}/cashouts") { val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout) @@ -687,7 +687,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } } } - auth(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") { val id = call.longPath("CASHOUT_ID") val cashout = db.cashout.get(id, call.username) ?: throw notFound( @@ -706,7 +706,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } } } - authAdmin(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat) { + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { get("/cashouts") { val params = PageParams.extract(call.request.queryParameters) val cashouts = db.cashout.pageAll(params) @@ -720,7 +720,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { val id = call.longPath("CHALLENGE_ID") val res = db.tan.send( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt @@ -23,7 +23,7 @@ import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* import tech.libeufin.bank.BankConfig -import tech.libeufin.bank.TokenScope +import tech.libeufin.bank.TokenLogicalScope import tech.libeufin.bank.auth.auth import tech.libeufin.bank.bankInfo import tech.libeufin.bank.db.Database @@ -32,7 +32,7 @@ import tech.libeufin.common.RevenueConfig import tech.libeufin.common.RevenueIncomingHistory fun Routing.revenueApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenScope.revenue, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.revenue, cfg.basicAuthCompat) { get("/accounts/{USERNAME}/taler-revenue/config") { call.respond(RevenueConfig( currency = cfg.regionalCurrency diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -40,7 +40,7 @@ import java.time.Instant fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { get("/accounts/{USERNAME}/taler-wire-gateway/config") { call.respond(WireGatewayConfig( currency = cfg.regionalCurrency @@ -81,7 +81,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { } } } - auth(db, cfg.pwCrypto, TokenScope.readonly, cfg.basicAuthCompat) { + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { suspend fun <T> ApplicationCall.historyEndpoint( reduce: (List<T>, String) -> Any, dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> @@ -146,7 +146,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { call.respond(transfer) } } - authAdmin(db, cfg.pwCrypto, TokenScope.readwrite, cfg.basicAuthCompat) { + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { suspend fun ApplicationCall.addIncoming( amount: TalerAmount, debitAccount: Payto, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -27,8 +27,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.util.* import io.ktor.util.pipeline.* -import tech.libeufin.bank.BearerToken -import tech.libeufin.bank.TokenScope +import tech.libeufin.bank.* import tech.libeufin.bank.db.AccountDAO.CheckPasswordResult import tech.libeufin.bank.db.Database import tech.libeufin.common.* @@ -65,7 +64,7 @@ val ApplicationCall.authToken: ByteArray? get() = attributes.getOrNull(AUTH_TOKE fun Route.authAdmin( db: Database, pwCrypto: PwCrypto, - scope: TokenScope, + scope: TokenLogicalScope, compatPw: Boolean, enforce: Boolean = true, callback: Route.() -> Unit @@ -98,7 +97,7 @@ fun Route.authAdmin( fun Route.auth( db: Database, pwCrypto: PwCrypto, - scope: TokenScope, + scope: TokenLogicalScope, compatPw: Boolean, allowAdmin: Boolean = false, requireAdmin: Boolean = false, @@ -127,7 +126,7 @@ fun Route.auth( private suspend fun ApplicationCall.authenticateBankRequest( db: Database, pwCrypto: PwCrypto, - requiredScope: TokenScope, + requiredScope: TokenLogicalScope, allowPw: Boolean, compatPw: Boolean ): String { @@ -184,15 +183,13 @@ private suspend fun doBasicAuth( } } -fun validScope(required: TokenScope, scope: TokenScope): Boolean { - val validScopes = when (required) { - TokenScope.readonly -> setOf(TokenScope.readonly, TokenScope.readwrite) - TokenScope.readwrite -> setOf(TokenScope.readwrite) - TokenScope.revenue -> setOf(TokenScope.readonly, TokenScope.readwrite, TokenScope.revenue) - TokenScope.refreshable -> return true - } - return validScopes.contains(scope) +fun validScope(required: TokenLogicalScope, scope: TokenScope): Boolean = when (required) { + TokenLogicalScope.readonly -> scope in setOf(TokenScope.readonly, TokenScope.readwrite) + TokenLogicalScope.readwrite -> scope in setOf(TokenScope.readwrite) + TokenLogicalScope.revenue -> scope in setOf(TokenScope.readonly, TokenScope.readwrite, TokenScope.revenue) + TokenLogicalScope.refreshable -> true } + /** * Performs the secret-token HTTP Bearer Authentication. * @@ -201,7 +198,7 @@ fun validScope(required: TokenScope, scope: TokenScope): Boolean { private suspend fun ApplicationCall.doTokenAuth( db: Database, bearer: String, - requiredScope: TokenScope, + requiredScope: TokenLogicalScope, ): String { if (!bearer.startsWith(TOKEN_PREFIX)) throw badRequest( "Bearer token malformed", @@ -222,7 +219,7 @@ private suspend fun ApplicationCall.doTokenAuth( !validScope(requiredScope, token.scope) -> throw unauthorized("Auth token has insufficient scope") - !token.isRefreshable && requiredScope == TokenScope.refreshable + !token.isRefreshable && requiredScope == TokenLogicalScope.refreshable -> throw unauthorized("Unrefreshable token") } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt @@ -0,0 +1,82 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.* +import tech.libeufin.bank.* +import tech.libeufin.common.* +import java.time.* +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit + +class CreateToken : CliktCommand("create-token") { + override fun help(context: Context) = "Create authentication token for a user" + private val common by CommonOption() + private val username by option("--username", "--user", "-u", help = "Account username").required() + private val scope by option("--scope", "-s", help = "Scope for the token").enum<TokenScope>().required() + private val duration by option("--duration", "-d", metavar = "<forever|micros>",help = "Custom token validity duration").convert { + if (it == "forever") { + ChronoUnit.FOREVER.duration + } else { + val dUs = it.toLongOrNull() ?: throw Exception("Expected forver or a number in micros") + when { + dUs < 0 -> throw Exception("Negative duration specified") + dUs > RelativeTime.MAX_SAFE_INTEGER -> throw Exception("Duration value exceed cap (2^53-1)") + else -> Duration.of(dUs, ChronoUnit.MICROS) + } + } + }.default(TOKEN_DEFAULT_DURATION) + private val description by option("--description", help = "Optional token description") + private val refreshable by option("--refreshable", help = "Make the token refreshable into a new token").flag() + + override fun run() = cliCmd(logger, common.log) { + bankConfig(common.config).withDb { db, cfg -> + val creationTime = Instant.now() + val expirationTimestamp = + if (duration == ChronoUnit.FOREVER.duration) { + Instant.MAX + } else { + try { + creationTime.plus(duration) + } catch (e: Exception) { + throw Exception("Bad token duration: ${e.message}") + } + } + val token = Base32Crockford32B.secureRand() + if (!db.token.create( + username = username, + content = token.raw, + creationTime = creationTime, + expirationTime = expirationTimestamp, + scope = scope, + isRefreshable = refreshable, + description = description + )) { + throw internalServerError("Unknown account $username") + } + println(token) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt @@ -29,7 +29,7 @@ import tech.libeufin.common.getVersion class LibeufinBank : CliktCommand() { init { versionOption(getVersion()) - subcommands(DbInit(), ChangePw(), Serve(), CreateAccount(), EditAccount(), GC(), BenchPwh(), CliConfigCmd(BANK_CONFIG_SOURCE)) + subcommands(DbInit(), ChangePw(), CreateToken(), Serve(), CreateAccount(), EditAccount(), GC(), BenchPwh(), CliConfigCmd(BANK_CONFIG_SOURCE)) } override fun run() = Unit diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -125,7 +125,7 @@ class NexusConfig internal constructor (val cfg: TalerConfig) { fun NexusConfig.checkCurrency(amount: TalerAmount) { if (amount.currency != currency) throw badRequest( - "Wrong currency: expected regional $currency got ${amount.currency}", + "Wrong currency: expected $currency got ${amount.currency}", TalerErrorCode.GENERIC_CURRENCY_MISMATCH ) }