commit 02b350b9b971cb41a04735c2e031d856af5220fb
parent a03d30cb885c9a2d1084b937fa9208316d2350ed
Author: Antoine A <>
Date: Wed, 17 Sep 2025 11:25:04 +0200
bank: load bank info at auth time and cache it
Diffstat:
9 files changed, 181 insertions(+), 95 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -276,9 +276,10 @@ data class MonitorWithConversion(
* from/to the database.
*/
data class BankInfo(
+ val username: String,
val payto: String,
val bankAccountId: Long,
- val isTalerExchange: Boolean,
+ val isTalerExchange: Boolean
)
// Allowed values for cashout TAN channels.
@@ -316,8 +317,7 @@ data class BearerToken(
val scope: TokenScope,
val isRefreshable: Boolean,
val creationTime: Instant,
- val expirationTime: Instant,
- val username: String
+ val expirationTime: Instant
)
@Serializable
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt
@@ -160,18 +160,18 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow
params.credit?.let { cfg.checkRegionalCurrency(it) }
if (params.debit != null) {
- call.convert(params.debit, { userToCashin(call.username, it) }) {
+ call.convert(params.debit, { userToCashin(call.pathUsername, it) }) {
ConversionResponse(params.debit, it)
}
} else {
- call.convert(params.credit!!, { userFromCashin(call.username, it) }) {
+ call.convert(params.credit!!, { userFromCashin(call.pathUsername, it) }) {
ConversionResponse(it, params.credit)
}
}
}
optAuth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
get("/accounts/{USERNAME}/conversion-info/rate") {
- val (isExchange, rate) = db.conversion.getUserRate(call.username)
+ val (isExchange, rate) = db.conversion.getUserRate(call.pathUsername)
if (!isExchange && !call.isAuthenticated) {
throw forbidden("Non exchange account rates are private")
}
@@ -186,11 +186,11 @@ fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allow
params.credit?.let { cfg.checkFiatCurrency(it) }
if (params.debit != null) {
- call.convert(params.debit, { userToCashout(call.username, it) }) {
+ call.convert(params.debit, { userToCashout(call.pathUsername, it) }) {
ConversionResponse(params.debit, it)
}
} else {
- call.convert(params.credit!!, { userFromCashout(call.username, it) }) {
+ call.convert(params.credit!!, { userFromCashout(call.pathUsername, it) }) {
ConversionResponse(it, params.credit)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -120,7 +120,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
}
}
when (db.token.create(
- username = call.username,
+ username = call.pathUsername,
content = token.raw,
creationTime = creationTime,
expirationTime = expirationTimestamp,
@@ -162,7 +162,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
get("/accounts/{USERNAME}/tokens") {
val params = PageParams.extract(call.request.queryParameters)
- val tokens = db.token.page(params, call.username, Instant.now())
+ val tokens = db.token.page(params, call.pathUsername, Instant.now())
if (tokens.isEmpty()) {
call.respond(HttpStatusCode.NoContent)
} else {
@@ -357,19 +357,19 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
val challenge = call.checkChallenge(db, Operation.account_delete)
// Not deleting reserved names.
- if (RESERVED_ACCOUNTS.contains(call.username))
+ if (RESERVED_ACCOUNTS.contains(call.pathUsername))
throw conflict(
"Cannot delete reserved accounts",
TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
)
- if (call.username == "exchange" && cfg.allowConversion)
+ if (call.pathUsername == "exchange" && cfg.allowConversion)
throw conflict(
"Cannot delete 'exchange' accounts when conversion is enabled",
TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
)
- when (db.account.delete(call.username, call.isAdmin || challenge != null)) {
- AccountDeletionResult.UnknownAccount -> throw unknownAccount(call.username)
+ when (db.account.delete(call.pathUsername, call.isAdmin || challenge != null)) {
+ AccountDeletionResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
AccountDeletionResult.BalanceNotZero -> throw conflict(
"Account balance is not zero.",
TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
@@ -382,13 +382,13 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
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)
+ val res = patchAccount(db, cfg, req, call.pathUsername, call.isAdmin, challenge != null, challenge?.channel, challenge?.info)
when (res) {
AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
is AccountPatchResult.TanRequired -> {
call.respondChallenge(db, Operation.account_reconfig, req, res.channel, res.info)
}
- AccountPatchResult.UnknownAccount -> throw unknownAccount(call.username)
+ AccountPatchResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
AccountPatchResult.NonAdminName -> throw conflict(
"non-admin user cannot change their legal name",
TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
@@ -424,10 +424,10 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
}
val newPassword = req.new_password.checkPw(cfg.pwdCheckQuality)
- when (db.account.reconfigPassword(call.username, newPassword, req.old_password, call.isAdmin || challenge != null, cfg.pwCrypto)) {
+ when (db.account.reconfigPassword(call.pathUsername, newPassword, req.old_password, call.isAdmin || challenge != null, cfg.pwCrypto)) {
AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent)
AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req)
- AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(call.username)
+ AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
"old password does not match",
TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD
@@ -457,7 +457,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
}
auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
get("/accounts/{USERNAME}") {
- val account = db.account.get(call.username) ?: throw unknownAccount(call.username)
+ val account = db.account.get(call.pathUsername) ?: throw unknownAccount(call.pathUsername)
call.respond(account)
}
}
@@ -479,7 +479,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
}
get("/accounts/{USERNAME}/transactions/{T_ID}") {
val tId = call.longPath("T_ID")
- val tx = db.transaction.get(tId, call.username) ?: throw notFound(
+ val tx = db.transaction.get(tId, call.pathUsername) ?: throw notFound(
"Bank transaction '$tId' not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
)
@@ -497,7 +497,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
val res = db.transaction.create(
creditAccountPayto = req.payto_uri,
- debitAccountUsername = call.username,
+ debitAccountUsername = call.pathUsername,
subject = subject,
amount = amount,
timestamp = Instant.now(),
@@ -508,7 +508,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
maxAmount = cfg.maxAmount
)
when (res) {
- BankTransactionResult.UnknownDebtor -> throw unknownAccount(call.username)
+ BankTransactionResult.UnknownDebtor -> throw unknownAccount(call.pathUsername)
BankTransactionResult.TanRequired -> {
call.respondChallenge(db, Operation.bank_transaction, req)
}
@@ -547,7 +547,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
req.suggested_amount?.run(cfg::checkRegionalCurrency)
val opId = UUID.randomUUID()
when (db.withdrawal.create(
- call.username,
+ call.pathUsername,
opId,
req.amount,
req.suggested_amount,
@@ -557,7 +557,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
cfg.minAmount,
cfg.maxAmount
)) {
- WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(call.username)
+ WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
WithdrawalCreationResult.AccountIsExchange -> throw conflict(
"Exchange account cannot perform withdrawal operation",
TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
@@ -585,7 +585,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
val (req, challenge) = call.receiveChallenge<BankAccountConfirmWithdrawalRequest>(db, Operation.withdrawal, BankAccountConfirmWithdrawalRequest())
req.amount?.run(cfg::checkRegionalCurrency)
when (db.withdrawal.confirm(
- call.username,
+ call.pathUsername,
id,
Instant.now(),
req.amount,
@@ -634,7 +634,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
}
post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") {
val opId = call.uuidPath("withdrawal_id")
- when (db.withdrawal.abort(opId, call.username)) {
+ when (db.withdrawal.abort(opId, call.pathUsername)) {
AbortResult.UnknownOperation -> throw notFound(
"Withdrawal operation $opId not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
@@ -667,7 +667,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio
cfg.checkFiatCurrency(req.amount_credit)
val res = db.cashout.create(
- username = call.username,
+ username = call.pathUsername,
requestUid = req.request_uid,
amountDebit = req.amount_debit,
amountCredit = req.amount_credit,
@@ -676,7 +676,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio
is2fa = challenge != null
)
when (res) {
- CashoutCreationResult.AccountNotFound -> throw unknownAccount(call.username)
+ CashoutCreationResult.AccountNotFound -> throw unknownAccount(call.pathUsername)
CashoutCreationResult.BadConversion -> throw conflict(
"Wrong currency conversion",
TalerErrorCode.BANK_BAD_CONVERSION
@@ -711,7 +711,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio
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(
+ val cashout = db.cashout.get(id, call.pathUsername) ?: throw notFound(
"Cashout operation $id not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
)
@@ -719,7 +719,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio
}
get("/accounts/{USERNAME}/cashouts") {
val params = PageParams.extract(call.request.queryParameters)
- val cashouts = db.cashout.pageForUser(params, call.username)
+ val cashouts = db.cashout.pageForUser(params, call.pathUsername)
if (cashouts.isEmpty()) {
call.respond(HttpStatusCode.NoContent)
} else {
@@ -746,7 +746,7 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) {
val id = call.longPath("CHALLENGE_ID")
val res = db.tan.send(
id = id,
- username = call.username,
+ username = call.pathUsername,
code = Tan.genCode(),
timestamp = Instant.now(),
retryCounter = TAN_RETRY_COUNTER,
@@ -822,7 +822,7 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) {
val code = req.tan.removePrefix("T-")
val res = db.tan.solve(
id = id,
- username = call.username,
+ username = call.pathUsername,
code = code,
timestamp = Instant.now(),
isAuth = call.isAuthenticated
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt
@@ -30,7 +30,7 @@ import io.ktor.util.pipeline.*
import tech.libeufin.bank.*
import tech.libeufin.bank.auth.auth
import tech.libeufin.bank.auth.authAdmin
-import tech.libeufin.bank.auth.username
+import tech.libeufin.bank.auth.pathUsername
import tech.libeufin.bank.db.Database
import tech.libeufin.bank.db.ExchangeDAO
import tech.libeufin.bank.db.ExchangeDAO.AddIncomingResult
@@ -52,12 +52,12 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) {
cfg.checkRegionalCurrency(req.amount)
val res = db.exchange.transfer(
req = req,
- username = call.username,
+ username = call.pathUsername,
timestamp = Instant.now()
)
when (res) {
- TransferResult.UnknownExchange -> throw unknownAccount(call.username)
- TransferResult.NotAnExchange -> throw notExchange(call.username)
+ TransferResult.UnknownExchange -> throw unknownAccount(call.pathUsername)
+ TransferResult.NotAnExchange -> throw notExchange(call.pathUsername)
TransferResult.BothPartyAreExchange -> throw conflict(
"Wire transfer attempted with credit and debit party being both exchange account",
TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
@@ -92,7 +92,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) {
val bankAccount = this.bankInfo(db)
if (!bankAccount.isTalerExchange)
- throw notExchange(username)
+ throw notExchange(pathUsername)
val items = db.exchange.dbLambda(params, bankAccount.bankAccountId)
@@ -112,7 +112,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) {
val params = TransferParams.extract(call.request.queryParameters)
val bankAccount = call.bankInfo(db)
if (!bankAccount.isTalerExchange)
- throw notExchange(call.username)
+ throw notExchange(call.pathUsername)
if (params.status != null && params.status != TransferStatusState.success && params.status != TransferStatusState.permanent_failure) {
call.respond(HttpStatusCode.NoContent)
@@ -129,7 +129,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) {
get("/accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID}") {
val bankAccount = call.bankInfo(db)
if (!bankAccount.isTalerExchange)
- throw notExchange(call.username)
+ throw notExchange(call.pathUsername)
val txId = call.longPath("ROW_ID")
val transfer = db.exchange.getTransfer(bankAccount.bankAccountId, txId) ?: throw notFound(
@@ -141,7 +141,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) {
get("/accounts/{USERNAME}/taler-wire-gateway/account/check") {
val bankAccount = call.bankInfo(db)
if (!bankAccount.isTalerExchange)
- throw notExchange(call.username)
+ throw notExchange(call.pathUsername)
val params = AccountCheckParams.extract(call.request.queryParameters)
val account = params.account.expectIban()
@@ -163,13 +163,13 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) {
amount = amount,
debitAccount = debitAccount,
subject = subject,
- username = username,
+ username = pathUsername,
timestamp = timestamp,
metadata = metadata
)
when (res) {
- AddIncomingResult.UnknownExchange -> throw unknownAccount(username)
- AddIncomingResult.NotAnExchange -> throw notExchange(username)
+ AddIncomingResult.UnknownExchange -> throw unknownAccount(pathUsername)
+ AddIncomingResult.NotAnExchange -> throw notExchange(pathUsername)
AddIncomingResult.UnknownDebtor -> throw conflict(
"Debtor account $debitAccount was not found",
TalerErrorCode.BANK_UNKNOWN_DEBTOR
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2023-2024 Stanisci and Dold.
+ * Copyright (C) 2023-2025 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
@@ -49,7 +49,7 @@ suspend inline fun <reified B> ApplicationCall.respondChallenge(
val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), body)
val code = Tan.genCode()
val id = db.tan.new(
- username = username,
+ username = pathUsername,
op = op,
body = json,
code = code,
@@ -76,7 +76,7 @@ suspend inline fun <reified B> ApplicationCall.receiveChallenge(
): Pair<B, Challenge?> {
val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull()
return if (id != null) {
- val challenge = db.tan.challenge(id, username, op)!!
+ val challenge = db.tan.challenge(id, pathUsername, op)!!
Pair(Json.decodeFromString(challenge.body), challenge)
} else {
if (default != null) {
@@ -98,7 +98,7 @@ suspend fun ApplicationCall.checkChallenge(
): Challenge? {
val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull()
return if (id != null) {
- db.tan.challenge(id, username, op)!!
+ db.tan.challenge(id, pathUsername, op)!!
} else {
null
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2024 Taler Systems S.A.
+ * Copyright (C) 2024-2025 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
@@ -46,10 +46,14 @@ private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin")
/** Used to store used auth token */
private val AUTH_TOKEN = AttributeKey<ByteArray>("auth_token")
+/** Used to store auth account info */
+val AUTH_INFO = AttributeKey<BankInfo>("auth_info")
+
+
const val TOKEN_PREFIX = "secret-token:"
/** Get username of the request account */
-val ApplicationCall.username: String get() = parameters.expect("USERNAME")
+val ApplicationCall.pathUsername: String get() = parameters.expect("USERNAME")
/** Check if current request is authenticated */
val ApplicationCall.isAuthenticated: Boolean get() = attributes.getOrNull(AUTH) == true
@@ -60,6 +64,7 @@ val ApplicationCall.isAdmin: Boolean get() = attributes.getOrNull(AUTH_IS_ADMIN)
/** Check auth token used for authentication */
val ApplicationCall.authToken: ByteArray? get() = attributes.getOrNull(AUTH_TOKEN)
+
/**
* Create an admin authenticated route for [scope].
*
@@ -76,12 +81,12 @@ fun Route.authAdmin(
callback: Route.() -> Unit
): Route = intercept("AuthAdmin", callback) {
if (enforce) {
- val username = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
- if (username != "admin") {
+ val info = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
+ if (info.username != "admin") {
throw forbidden("Only administrator allowed")
}
} else {
- val username = try {
+ try {
this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
} catch (e: Exception) {
null
@@ -108,13 +113,13 @@ fun Route.auth(
allowPw: Boolean = false,
callback: Route.() -> Unit
): Route = intercept("Auth", callback) {
- val authUsername = this.authenticateBankRequest(db, pwCrypto, scope, allowPw, compatPw)
- if (requireAdmin && authUsername != "admin") {
+ val info = this.authenticateBankRequest(db, pwCrypto, scope, allowPw, compatPw)
+ if (requireAdmin && info.username != "admin") {
throw forbidden("Only administrator allowed")
} else {
- val hasRight = authUsername == username || (allowAdmin && authUsername == "admin")
+ val hasRight = info.username == pathUsername || (allowAdmin && info.username == "admin")
if (!hasRight) {
- throw forbidden("Customer $authUsername have no right on $username account")
+ throw forbidden("Customer ${info.username} have no right on $pathUsername account")
}
}
}
@@ -134,10 +139,10 @@ fun Route.optAuth(
): Route = intercept("Auth", callback) {
val header = request.headers[HttpHeaders.Authorization]
if (header != null) {
- val authUsername = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
- val hasRight = authUsername == username || (allowAdmin && authUsername == "admin")
+ val info = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw)
+ val hasRight = info.username == pathUsername || (allowAdmin && info.username == "admin")
if (!hasRight) {
- throw forbidden("Customer $authUsername have no right on $username account")
+ throw forbidden("Customer ${info.username} have no right on $pathUsername account")
}
}
}
@@ -160,7 +165,7 @@ private suspend fun ApplicationCall.authenticateBankRequest(
requiredScope: TokenLogicalScope,
allowPw: Boolean,
compatPw: Boolean
-): String {
+): BankInfo {
val header = request.headers[HttpHeaders.Authorization]
// Basic auth challenge
@@ -176,15 +181,16 @@ private suspend fun ApplicationCall.authenticateBankRequest(
"Authorization is invalid",
TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
)
- val username = when (scheme) {
+ val info = when (scheme) {
"Basic" -> doBasicAuth(db, content, pwCrypto, allowPw, compatPw)
"Bearer" -> doTokenAuth(db, content, requiredScope)
else -> throw unauthorized("Authorization method '$scheme' wrong or not supported")
}
this.attributes.put(AUTH, true)
- this.attributes.put(AUTH_IS_ADMIN, username == "admin")
- return username
+ this.attributes.put(AUTH_IS_ADMIN, info.username == "admin")
+ this.attributes.put(AUTH_INFO, info)
+ return info
}
/**
@@ -198,7 +204,7 @@ private suspend fun doBasicAuth(
pwCrypto: PwCrypto,
allowPw: Boolean,
compatPw: Boolean
-): String {
+): BankInfo {
val decoded = String(encoded.decodeBase64(), Charsets.UTF_8)
val (username, plainPassword) = decoded.splitOnce(":") ?: throw badRequest(
"Malformed Basic auth credentials found in the Authorization header",
@@ -210,11 +216,11 @@ private suspend fun doBasicAuth(
throw unauthorized("Authorization method 'Basic' not supported")
}
}
- return when (db.account.checkPassword(username, plainPassword, pwCrypto)) {
+ when (val res = db.account.checkPassword(username, plainPassword, pwCrypto)) {
CheckPasswordResult.UnknownAccount -> throw unauthorized("Unknown account")
CheckPasswordResult.PasswordMismatch -> throw unauthorized("Bad password")
CheckPasswordResult.Locked -> throw forbidden("Account is locked", TalerErrorCode.BANK_ACCOUNT_LOCKED)
- CheckPasswordResult.Success -> username
+ is CheckPasswordResult.Success -> return res.info
}
}
@@ -236,7 +242,7 @@ private suspend fun ApplicationCall.doTokenAuth(
db: Database,
bearer: String,
requiredScope: TokenLogicalScope,
-): String {
+): BankInfo {
if (!bearer.startsWith(TOKEN_PREFIX)) throw badRequest(
"Bearer token malformed",
TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
@@ -248,7 +254,7 @@ private suspend fun ApplicationCall.doTokenAuth(
e.message, TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
)
}
- val token: BearerToken = db.token.access(decoded, Instant.now()) ?: throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
+ val (token, info) = db.token.accessInfo(decoded, Instant.now()) ?: throw unauthorized("Unknown token", TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
when {
token.expirationTime.isBefore(Instant.now())
-> throw unauthorized("Expired auth token", TalerErrorCode.GENERIC_TOKEN_EXPIRED)
@@ -260,7 +266,8 @@ private suspend fun ApplicationCall.doTokenAuth(
-> throw forbidden("Unrefreshable token", TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT)
}
- attributes.put(AUTH_TOKEN, decoded)
+ this.attributes.put(AUTH_TOKEN, decoded)
+ this.attributes.put(AUTH_INFO, info)
- return token.username
+ return info
}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -447,26 +447,44 @@ class AccountDAO(private val db: Database) {
}
/** Result status of customer account password check */
- enum class CheckPasswordResult {
- UnknownAccount,
- PasswordMismatch,
- Locked,
- Success
+ sealed interface CheckPasswordResult {
+ data object UnknownAccount: CheckPasswordResult
+ data object PasswordMismatch: CheckPasswordResult
+ data object Locked: CheckPasswordResult
+ data class Success(val info: BankInfo): CheckPasswordResult
}
- /** Check password of account [username] against [pw], rehashing it if outdated */
+ /** Check password of account [username] against [pw], rehashing it if outdated and returning info */
suspend fun checkPassword(username: String, pw: String, pwCrypto: PwCrypto): CheckPasswordResult {
// Get user current password hash
- val info = db.serializable(
- "SELECT customer_id, password_hash, token_creation_counter FROM customers WHERE username=? AND deleted_at IS NULL"
+ val res = db.serializable(
+ """
+ SELECT
+ username,
+ password_hash,
+ token_creation_counter,
+ bank_account_id,
+ internal_payto,
+ is_taler_exchange,
+ name
+ FROM bank_accounts
+ JOIN customers ON customer_id=owning_customer_id
+ WHERE username=? AND deleted_at IS NULL
+ """
) {
bind(username)
- oneOrNull {
- Triple(it.getLong(1), it.getString(2), it.getInt(3))
+ oneOrNull {
+ val info = BankInfo(
+ username = it.getString("username"),
+ payto = it.getBankPayto("internal_payto", "name", db.ctx),
+ bankAccountId = it.getLong("bank_account_id"),
+ isTalerExchange = it.getBoolean("is_taler_exchange")
+ )
+ Triple(info, it.getString("password_hash"), it.getInt("token_creation_counter"))
}
}
- if (info == null) return CheckPasswordResult.UnknownAccount
- val (customerId, currentPwh, tokenCreationCounter) = info
+ if (res == null) return CheckPasswordResult.UnknownAccount
+ val (info, currentPwh, tokenCreationCounter) = res
// Check locked
if (tokenCreationCounter >= MAX_TOKEN_CREATION_ATTEMPTS) return CheckPasswordResult.Locked
@@ -479,16 +497,16 @@ class AccountDAO(private val db: Database) {
if (check.outdated) {
val newPwh = pwCrypto.hashpw(pw)
db.serializable(
- "UPDATE customers SET password_hash=? where customer_id=? AND password_hash=?"
+ "UPDATE customers SET password_hash=? where username=? AND password_hash=?"
) {
bind(newPwh)
- bind(customerId)
+ bind(username)
bind(currentPwh)
executeUpdate()
}
}
- return CheckPasswordResult.Success
+ return CheckPasswordResult.Success(info)
}
/** Get bank info of account [username] */
@@ -507,9 +525,10 @@ class AccountDAO(private val db: Database) {
bind(username)
oneOrNull {
BankInfo(
+ username = username,
payto = it.getBankPayto("internal_payto", "name", db.ctx),
- isTalerExchange = it.getBoolean("is_taler_exchange"),
- bankAccountId = it.getLong("bank_account_id")
+ bankAccountId = it.getLong("bank_account_id"),
+ isTalerExchange = it.getBoolean("is_taler_exchange")
)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
@@ -19,9 +19,7 @@
package tech.libeufin.bank.db
-import tech.libeufin.bank.BearerToken
-import tech.libeufin.bank.TokenInfo
-import tech.libeufin.bank.TokenScope
+import tech.libeufin.bank.*
import tech.libeufin.common.PageParams
import tech.libeufin.common.asInstant
import tech.libeufin.common.db.*
@@ -79,7 +77,6 @@ class TokenDAO(private val db: Database) {
RETURNING
creation_time,
expiration_time,
- username,
scope,
is_refreshable
"""
@@ -90,12 +87,51 @@ class TokenDAO(private val db: Database) {
BearerToken(
creationTime = it.getLong("creation_time").asInstant(),
expirationTime = it.getLong("expiration_time").asInstant(),
- username = it.getString("username"),
scope = it.getEnum("scope"),
isRefreshable = it.getBoolean("is_refreshable")
)
}
}
+
+ /** Get info for [token] and its associated bank account*/
+ suspend fun accessInfo(token: ByteArray, accessTime: Instant): Pair<BearerToken, BankInfo>? = db.serializable(
+ """
+ UPDATE bearer_tokens
+ SET last_access=?
+ FROM customers
+ JOIN bank_accounts ON customer_id=owning_customer_id
+ WHERE bank_customer=customer_id AND content=? AND deleted_at IS NULL
+ RETURNING
+ creation_time,
+ expiration_time,
+ scope,
+ is_refreshable,
+ username,
+ is_taler_exchange,
+ bank_account_id,
+ internal_payto,
+ name
+ """
+ ) {
+ bind(accessTime)
+ bind(token)
+ oneOrNull {
+ Pair(
+ BearerToken(
+ creationTime = it.getLong("creation_time").asInstant(),
+ expirationTime = it.getLong("expiration_time").asInstant(),
+ scope = it.getEnum("scope"),
+ isRefreshable = it.getBoolean("is_refreshable")
+ ),
+ BankInfo(
+ username = it.getString("username"),
+ payto = it.getBankPayto("internal_payto", "name", db.ctx),
+ bankAccountId = it.getLong("bank_account_id"),
+ isTalerExchange = it.getBoolean("is_taler_exchange")
+ )
+ )
+ }
+ }
/** Delete token [token] */
suspend fun delete(token: ByteArray) = db.serializable(
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -20,6 +20,7 @@
package tech.libeufin.bank
import io.ktor.http.*
+import io.ktor.util.*
import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.request.*
@@ -32,16 +33,39 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
-import tech.libeufin.bank.auth.username
+import tech.libeufin.bank.auth.pathUsername
+import tech.libeufin.bank.auth.AUTH_INFO
import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
import tech.libeufin.common.api.intercept
import java.util.*
+private val BANK_INFO = AttributeKey<BankInfo>("bank_info")
+
/** Retrieve the bank account info for the selected username*/
-suspend fun ApplicationCall.bankInfo(db: Database): BankInfo
- = db.account.bankInfo(username) ?: throw unknownAccount(username)
+suspend fun ApplicationCall.bankInfo(db: Database): BankInfo {
+ val username = this.pathUsername
+
+ // CHeck cached bank info
+ val cachedInfo = this.attributes.getOrNull(BANK_INFO)
+ if (cachedInfo != null) {
+ require(cachedInfo.username == username)
+ return cachedInfo
+ }
+
+ // Check cached auth info
+ val authInfo = this.attributes.getOrNull(AUTH_INFO)
+ if (authInfo != null && authInfo.username == username) {
+ this.attributes.put(BANK_INFO, authInfo)
+ return authInfo
+ }
+
+ // Else load from db
+ val info = db.account.bankInfo(pathUsername) ?: throw unknownAccount(pathUsername)
+ this.attributes.put(BANK_INFO, info)
+ return info
+}
private fun ApplicationRequest.fallbackBase() = BaseURL.parse(url {
protocol = URLProtocol(