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