libeufin

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

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:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 10+++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 48++++++++++++++++++++++++------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 22+++++++++++-----------
Mbank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt | 8++++----
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 53++++++++++++++++++++++++++++++-----------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 53++++++++++++++++++++++++++++++++++++-----------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 46+++++++++++++++++++++++++++++++++++++++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 30+++++++++++++++++++++++++++---
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(