libeufin

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

commit 807eb3fa7eb4a0d555646865a370f5f66cbaa4fc
parent 9af4fd9bcb338f20e75254f1aaad9ee7b1c0df47
Author: Antoine A <>
Date:   Wed,  6 Mar 2024 10:42:20 +0100

Clean and refactor code

Diffstat:
Dbank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt | 107-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt | 117-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 742-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 1+
Dbank/src/main/kotlin/tech/libeufin/bank/Params.kt | 164-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt | 48------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt | 156-------------------------------------------------------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 743+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 38+++++++++++++++++---------------------
Abank/src/main/kotlin/tech/libeufin/bank/params.kt | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 7++++---
Mbank/src/test/kotlin/CoreBankApiTest.kt | 7++++---
17 files changed, 1379 insertions(+), 1362 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -1,106 +0,0 @@ -/* - * 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/> - */ - -/* This file contains the Taler Integration API endpoints, -* that are typically requested by wallets. */ -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import tech.libeufin.bank.db.AbortResult -import tech.libeufin.bank.db.Database -import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult -import tech.libeufin.common.TalerErrorCode - -fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { - get("/taler-integration/config") { - call.respond(TalerIntegrationConfigResponse( - currency = ctx.regionalCurrency, - currency_specification = ctx.regionalCurrencySpec - )) - } - - // Note: wopid acts as an authentication token. - get("/taler-integration/withdrawal-operation/{wopid}") { - val uuid = call.uuidParameter("wopid") - val params = StatusParams.extract(call.request.queryParameters) - val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod) ?: throw notFound( - "Withdrawal operation '$uuid' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - call.respond(op.copy( - suggested_exchange = ctx.suggestedWithdrawalExchange, - confirm_transfer_url = if (op.status == WithdrawalStatus.pending || op.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null - )) - } - post("/taler-integration/withdrawal-operation/{wopid}") { - val uuid = call.uuidParameter("wopid") - val req = call.receive<BankWithdrawalOperationPostRequest>() - - val res = db.withdrawal.setDetails( - uuid, req.selected_exchange, req.reserve_pub - ) - when (res) { - is WithdrawalSelectionResult.UnknownOperation -> throw notFound( - "Withdrawal operation '$uuid' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - is WithdrawalSelectionResult.AlreadySelected -> throw conflict( - "Cannot select different exchange and reserve pub. under the same withdrawal operation", - TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT - ) - is WithdrawalSelectionResult.RequestPubReuse -> throw conflict( - "Reserve pub. already used", - TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT - ) - is WithdrawalSelectionResult.UnknownAccount -> throw conflict( - "Account ${req.selected_exchange} not found", - TalerErrorCode.BANK_UNKNOWN_ACCOUNT - ) - is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict( - "Account ${req.selected_exchange} is not an exchange", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - is WithdrawalSelectionResult.Success -> { - call.respond(BankWithdrawalOperationPostResponse( - transfer_done = res.status == WithdrawalStatus.confirmed, - status = res.status, - confirm_transfer_url = if (res.status == WithdrawalStatus.pending || res.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null - )) - } - } - } - post("/taler-integration/withdrawal-operation/{wopid}/abort") { - val uuid = call.uuidParameter("wopid") - when (db.withdrawal.abort(uuid)) { - AbortResult.UnknownOperation -> throw notFound( - "Withdrawal operation '$uuid' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - AbortResult.AlreadyConfirmed -> throw conflict( - "Cannot abort confirmed withdrawal", - TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT - ) - AbortResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt @@ -1,116 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 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 - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import tech.libeufin.bank.auth.authAdmin -import tech.libeufin.bank.db.ConversionDAO -import tech.libeufin.bank.db.ConversionDAO.ConversionResult -import tech.libeufin.bank.db.Database -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.TalerErrorCode - -fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { - get("/conversion-info/config") { - val config = db.conversion.getConfig(ctx.regionalCurrency, ctx.fiatCurrency!!) - if (config == null) { - throw libeufinError( - HttpStatusCode.NotImplemented, - "conversion rate not configured yet", - TalerErrorCode.END - ) - } - call.respond( - ConversionConfig( - regional_currency = ctx.regionalCurrency, - regional_currency_specification = ctx.regionalCurrencySpec, - fiat_currency = ctx.fiatCurrency, - fiat_currency_specification = ctx.fiatCurrencySpec!!, - conversion_rate = config - ) - ) - } - suspend fun ApplicationCall.convert( - input: TalerAmount, - conversion: suspend ConversionDAO.(TalerAmount) -> ConversionResult, - output: (TalerAmount) -> ConversionResponse - ) { - when (val res = db.conversion.(conversion)(input)) { - is ConversionResult.Success -> respond(output(res.converted)) - is ConversionResult.ToSmall -> throw conflict( - "$input is too small to be converted", - TalerErrorCode.BANK_BAD_CONVERSION - ) - is ConversionResult.MissingConfig -> throw libeufinError( - HttpStatusCode.NotImplemented, - "conversion rate not configured yet", - TalerErrorCode.END - ) - } - } - get("/conversion-info/cashout-rate") { - val params = RateParams.extract(call.request.queryParameters) - - params.debit?.let { ctx.checkRegionalCurrency(it) } - params.credit?.let { ctx.checkFiatCurrency(it) } - - if (params.debit != null) { - call.convert(params.debit, ConversionDAO::toCashout) { - ConversionResponse(params.debit, it) - } - } else { - call.convert(params.credit!!, ConversionDAO::fromCashout) { - ConversionResponse(it, params.credit) - } - } - } - get("/conversion-info/cashin-rate") { - val params = RateParams.extract(call.request.queryParameters) - - params.debit?.let { ctx.checkFiatCurrency(it) } - params.credit?.let { ctx.checkRegionalCurrency(it) } - - if (params.debit != null) { - call.convert(params.debit, ConversionDAO::toCashin) { - ConversionResponse(params.debit, it) - } - } else { - call.convert(params.credit!!, ConversionDAO::fromCashin) { - ConversionResponse(it, params.credit) - } - } - } - authAdmin(db, TokenScope.readwrite) { - 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)) { - ctx.checkRegionalCurrency(regionalAmount) - } - for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) { - ctx.checkFiatCurrency(fiatAmount) - } - db.conversion.updateConfig(req) - call.respond(HttpStatusCode.NoContent) - } - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -1,742 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023-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 - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.future.await -import kotlinx.coroutines.withContext -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.bank.auth.* -import tech.libeufin.bank.db.AbortResult -import tech.libeufin.bank.db.AccountDAO.* -import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult -import tech.libeufin.bank.db.Database -import tech.libeufin.bank.db.TanDAO.TanSendResult -import tech.libeufin.bank.db.TanDAO.TanSolveResult -import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult -import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult -import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult -import tech.libeufin.common.* -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.* - -private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api") - -fun Routing.coreBankApi(db: Database, ctx: BankConfig) { - get("/config") { - call.respond( - Config( - bank_name = ctx.name, - currency = ctx.regionalCurrency, - currency_specification = ctx.regionalCurrencySpec, - allow_conversion = ctx.allowConversion, - allow_registrations = ctx.allowRegistration, - allow_deletions = ctx.allowAccountDeletion, - default_debit_threshold = ctx.defaultDebtLimit, - supported_tan_channels = ctx.tanChannels.keys, - allow_edit_name = ctx.allowEditName, - allow_edit_cashout_payto_uri = ctx.allowEditCashout, - wire_type = ctx.wireMethod - ) - ) - } - authAdmin(db, TokenScope.readonly) { - get("/monitor") { - val params = MonitorParams.extract(call.request.queryParameters) - call.respond(db.monitor(params)) - } - } - coreBankTokenApi(db) - coreBankAccountsApi(db, ctx) - coreBankTransactionsApi(db, ctx) - coreBankWithdrawalApi(db, ctx) - coreBankCashoutApi(db, ctx) - coreBankTanApi(db, ctx) -} - -private fun Routing.coreBankTokenApi(db: Database) { - val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) - auth(db, TokenScope.refreshable) { - post("/accounts/{USERNAME}/token") { - val existingToken = call.authToken - val req = call.receive<TokenRequest>() - - if (existingToken != null) { - // This block checks permissions ONLY IF the call was authenticated with a token - val refreshingToken = db.token.get(existingToken) ?: throw internalServerError( - "Token used to auth not found in the database!" - ) - if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) - throw forbidden( - "Cannot generate RW token from RO", - TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT - ) - } - val token = Base32Crockford32B.rand() - val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION - - val creationTime = Instant.now() - val expirationTimestamp = - if (tokenDuration == ChronoUnit.FOREVER.duration) { - logger.debug("Creating 'forever' token.") - Instant.MAX - } else { - try { - logger.debug("Creating token with days duration: ${tokenDuration.toDays()}") - creationTime.plus(tokenDuration) - } catch (e: Exception) { - throw badRequest("Bad token duration: ${e.message}") - } - } - if (!db.token.create( - login = username, - content = token.raw, - creationTime = creationTime, - expirationTime = expirationTimestamp, - scope = req.scope, - isRefreshable = req.refreshable - )) { - throw internalServerError("Failed at inserting new token in the database") - } - call.respond( - TokenSuccessResponse( - access_token = token.encoded(), - expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) - ) - ) - } - } - auth(db, TokenScope.readonly) { - delete("/accounts/{USERNAME}/token") { - val token = call.authToken ?: throw badRequest("Basic auth not supported here.") - db.token.delete(token) - call.respond(HttpStatusCode.NoContent) - } - } -} - -suspend fun createAccount( - db: Database, - cfg: BankConfig, - req: RegisterAccountRequest, - isAdmin: Boolean -): Pair<AccountCreationResult, String> { - // Prohibit reserved usernames: - if (RESERVED_ACCOUNTS.contains(req.username)) - throw conflict( - "Username '${req.username}' is reserved", - TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT - ) - - if (!isAdmin) { - if (req.debit_threshold != null) - throw conflict( - "only admin account can choose the debit limit", - TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT - ) - - if (req.tan_channel != null) - throw conflict( - "only admin account can enable 2fa on creation", - TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL - ) - - } else if (req.tan_channel != null) { - if (cfg.tanChannels.get(req.tan_channel) == null) { - throw unsupportedTanChannel(req.tan_channel) - } - val missing = when (req.tan_channel) { - TanChannel.sms -> req.contact_data?.phone?.get() == null - TanChannel.email -> req.contact_data?.email?.get() == null - } - if (missing) - throw conflict( - "missing info for tan channel ${req.tan_channel}", - TalerErrorCode.BANK_MISSING_TAN_INFO - ) - } - - if (req.username == "exchange" && !req.is_taler_exchange) - throw conflict( - "'exchange' account must be a taler exchange account", - TalerErrorCode.END - ) - - when (cfg.wireMethod) { - WireMethod.IBAN -> { - if (req.payto_uri != null && !(req.payto_uri is IbanPayto)) - throw badRequest("Expected an IBAN payto uri") - var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0 - - while (true) { - val internalPayto = req.payto_uri ?: IbanPayto.rand() as Payto - val res = db.account.create( - login = req.username, - name = req.name, - email = req.contact_data?.email?.get(), - phone = req.contact_data?.phone?.get(), - cashoutPayto = req.cashout_payto_uri, - password = req.password, - internalPayto = internalPayto, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, - bonus = if (!req.is_taler_exchange) cfg.registrationBonus - else TalerAmount(0, 0, cfg.regionalCurrency), - tanChannel = req.tan_channel, - checkPaytoIdempotent = req.payto_uri != null - ) - // Retry with new IBAN - if (res == AccountCreationResult.PayToReuse && retry > 0) { - retry-- - continue - } - return Pair(res, internalPayto.bank(req.name, cfg.payto)) - } - } - WireMethod.X_TALER_BANK -> { - if (req.payto_uri != null) { - if (!(req.payto_uri is XTalerBankPayto)) - throw badRequest("Expected an IBAN payto uri") - else if (req.payto_uri.username != req.username) - throw badRequest("Expected a payto uri for '${req.username}' got one for '${req.payto_uri.username}'") - } - - val internalPayto = XTalerBankPayto.forUsername(req.username) - - val res = db.account.create( - login = req.username, - name = req.name, - email = req.contact_data?.email?.get(), - phone = req.contact_data?.phone?.get(), - cashoutPayto = req.cashout_payto_uri, - password = req.password, - internalPayto = internalPayto, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, - bonus = if (!req.is_taler_exchange) cfg.registrationBonus - else TalerAmount(0, 0, cfg.regionalCurrency), - tanChannel = req.tan_channel, - checkPaytoIdempotent = req.payto_uri != null - ) - return Pair(res, internalPayto.bank(req.name, cfg.payto)) - } - } -} - -suspend fun patchAccount( - db: Database, - cfg: BankConfig, - req: AccountReconfiguration, - username: String, - isAdmin: Boolean, - is2fa: Boolean, - channel: TanChannel? = null, - info: String? = null -): AccountPatchResult { - req.debit_threshold?.run { cfg.checkRegionalCurrency(this) } - - if (username == "admin" && req.is_public == true) - throw conflict( - "'admin' account cannot be public", - TalerErrorCode.END - ) - - if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) { - throw unsupportedTanChannel(req.tan_channel.value) - } - - return db.account.reconfig( - login = username, - name = req.name, - cashoutPayto = req.cashout_payto_uri, - email = req.contact_data?.email ?: Option.None, - phone = req.contact_data?.phone ?: Option.None, - tan_channel = req.tan_channel, - isPublic = req.is_public, - debtLimit = req.debit_threshold, - isAdmin = isAdmin, - is2fa = is2fa, - faChannel = channel, - faInfo = info, - allowEditName = cfg.allowEditName, - allowEditCashout = cfg.allowEditCashout - ) -} - -private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { - authAdmin(db, TokenScope.readwrite, !ctx.allowRegistration) { - post("/accounts") { - val req = call.receive<RegisterAccountRequest>() - val (result, internalPayto) = createAccount(db, ctx, req, isAdmin) - when (result) { - AccountCreationResult.BonusBalanceInsufficient -> throw conflict( - "Insufficient admin funds to grant bonus", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - AccountCreationResult.LoginReuse -> throw conflict( - "Account username reuse '${req.username}'", - TalerErrorCode.BANK_REGISTER_USERNAME_REUSE - ) - AccountCreationResult.PayToReuse -> throw conflict( - "Bank internalPayToUri reuse '$internalPayto'", - TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE - ) - AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto)) - } - } - } - auth( - db, - TokenScope.readwrite, - allowAdmin = true, - requireAdmin = !ctx.allowAccountDeletion - ) { - delete("/accounts/{USERNAME}") { - val challenge = call.checkChallenge(db, Operation.account_delete) - - // Not deleting reserved names. - if (RESERVED_ACCOUNTS.contains(username)) - throw conflict( - "Cannot delete reserved accounts", - TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT - ) - if (username == "exchange" && ctx.allowConversion) - throw conflict( - "Cannot delete 'exchange' accounts when conversion is enabled", - TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT - ) - - when (db.account.delete(username, isAdmin || challenge != null)) { - AccountDeletionResult.UnknownAccount -> throw unknownAccount(username) - AccountDeletionResult.BalanceNotZero -> throw conflict( - "Account balance is not zero.", - TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO - ) - AccountDeletionResult.TanRequired -> call.respondChallenge(db, Operation.account_delete, Unit) - AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } - } - auth(db, TokenScope.readwrite, allowAdmin = true) { - patch("/accounts/{USERNAME}") { - val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig) - val res = patchAccount(db, ctx, req, username, 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(username) - AccountPatchResult.NonAdminName -> throw conflict( - "non-admin user cannot change their legal name", - TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME - ) - AccountPatchResult.NonAdminCashout -> throw conflict( - "non-admin user cannot change their cashout account", - TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT - ) - AccountPatchResult.NonAdminDebtLimit -> throw conflict( - "non-admin user cannot change their debt limit", - TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT - ) - AccountPatchResult.MissingTanInfo -> throw conflict( - "missing info for tan channel ${req.tan_channel.get()}", - TalerErrorCode.BANK_MISSING_TAN_INFO - ) - } - } - patch("/accounts/{USERNAME}/auth") { - val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig) - - if (!isAdmin && req.old_password == null) { - throw conflict( - "non-admin user cannot change password without providing old password", - TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD - ) - } - when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null)) { - AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent) - AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req) - AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(username) - AccountPatchAuthResult.OldPasswordMismatch -> throw conflict( - "old password does not match", - TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD - ) - } - } - } - get("/public-accounts") { - val params = AccountParams.extract(call.request.queryParameters) - val publicAccounts = db.account.pagePublic(params, ctx.payto) - if (publicAccounts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(PublicAccountsResponse(publicAccounts)) - } - } - authAdmin(db, TokenScope.readonly) { - get("/accounts") { - val params = AccountParams.extract(call.request.queryParameters) - val accounts = db.account.pageAdmin(params, ctx.payto) - if (accounts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(ListBankAccountsResponse(accounts)) - } - } - } - auth(db, TokenScope.readonly, allowAdmin = true) { - get("/accounts/{USERNAME}") { - val account = db.account.get(username, ctx.payto) ?: throw unknownAccount(username) - call.respond(account) - } - } -} - -private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readonly) { - get("/accounts/{USERNAME}/transactions") { - val params = HistoryParams.extract(call.request.queryParameters) - val bankAccount = call.bankInfo(db, ctx.payto) - - val history: List<BankAccountTransactionInfo> = - db.transaction.pollHistory(params, bankAccount.bankAccountId, ctx.payto) - if (history.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(BankAccountTransactionsResponse(history)) - } - } - get("/accounts/{USERNAME}/transactions/{T_ID}") { - val tId = call.longParameter("T_ID") - val tx = db.transaction.get(tId, username, ctx.payto) ?: throw notFound( - "Bank transaction '$tId' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - call.respond(tx) - } - } - auth(db, TokenScope.readwrite) { - post("/accounts/{USERNAME}/transactions") { - val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction) - - val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") - val amount = req.payto_uri.amount ?: req.amount ?: throw badRequest("Wire transfer lacks amount") - - ctx.checkRegionalCurrency(amount) - - val res = db.transaction.create( - creditAccountPayto = req.payto_uri, - debitAccountUsername = username, - subject = subject, - amount = amount, - timestamp = Instant.now(), - is2fa = challenge != null - ) - when (res) { - BankTransactionResult.UnknownDebtor -> throw unknownAccount(username) - BankTransactionResult.TanRequired -> { - call.respondChallenge(db, Operation.bank_transaction, req) - } - BankTransactionResult.BothPartySame -> throw conflict( - "Wire transfer attempted with credit and debit party being the same bank account", - TalerErrorCode.BANK_SAME_ACCOUNT - ) - BankTransactionResult.UnknownCreditor -> throw unknownCreditorAccount(req.payto_uri.canonical) - BankTransactionResult.AdminCreditor -> throw conflict( - "Cannot transfer money to admin account", - TalerErrorCode.BANK_ADMIN_CREDITOR - ) - BankTransactionResult.BalanceInsufficient -> throw conflict( - "Insufficient funds", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id)) - } - } - } -} - -private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readwrite) { - post("/accounts/{USERNAME}/withdrawals") { - val req = call.receive<BankAccountCreateWithdrawalRequest>() - ctx.checkRegionalCurrency(req.amount) - val opId = UUID.randomUUID() - when (db.withdrawal.create(username, opId, req.amount, Instant.now())) { - WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username) - WithdrawalCreationResult.AccountIsExchange -> throw conflict( - "Exchange account cannot perform withdrawal operation", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - WithdrawalCreationResult.BalanceInsufficient -> throw conflict( - "Insufficient funds to withdraw with Taler", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - WithdrawalCreationResult.Success -> { - call.respond( - BankAccountCreateWithdrawalResponse( - withdrawal_id = opId.toString(), - taler_withdraw_uri = call.request.talerWithdrawUri(opId) - ) - ) - } - } - } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { - val id = call.uuidParameter("withdrawal_id") - val challenge = call.checkChallenge(db, Operation.withdrawal) - when (db.withdrawal.confirm(username, id, Instant.now(), challenge != null)) { - WithdrawalConfirmationResult.UnknownOperation -> throw notFound( - "Withdrawal operation $id not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - WithdrawalConfirmationResult.AlreadyAborted -> throw conflict( - "Cannot confirm an aborted withdrawal", - TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT - ) - WithdrawalConfirmationResult.NotSelected -> throw conflict( - "Cannot confirm an unselected withdrawal", - TalerErrorCode.BANK_CONFIRM_INCOMPLETE - ) - WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict( - "Insufficient funds", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - WithdrawalConfirmationResult.UnknownExchange -> throw conflict( - "Exchange to withdraw from not found", - TalerErrorCode.BANK_UNKNOWN_CREDITOR - ) - WithdrawalConfirmationResult.TanRequired -> { - call.respondChallenge(db, Operation.withdrawal, StoredUUID(id)) - } - WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { - val opId = call.uuidParameter("withdrawal_id") - when (db.withdrawal.abort(opId)) { - AbortResult.UnknownOperation -> throw notFound( - "Withdrawal operation $opId not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - AbortResult.AlreadyConfirmed -> throw conflict( - "Cannot abort confirmed withdrawal", - TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT - ) - AbortResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } - } - get("/withdrawals/{withdrawal_id}") { - val uuid = call.uuidParameter("withdrawal_id") - val params = StatusParams.extract(call.request.queryParameters) - val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound( - "Withdrawal operation '$uuid' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - call.respond(op) - } -} - -private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { - auth(db, TokenScope.readwrite) { - post("/accounts/{USERNAME}/cashouts") { - val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout) - - ctx.checkRegionalCurrency(req.amount_debit) - ctx.checkFiatCurrency(req.amount_credit) - - val res = db.cashout.create( - login = username, - requestUid = req.request_uid, - amountDebit = req.amount_debit, - amountCredit = req.amount_credit, - subject = req.subject ?: "", // TODO default subject - now = Instant.now(), - is2fa = challenge != null - ) - when (res) { - CashoutCreationResult.AccountNotFound -> throw unknownAccount(username) - CashoutCreationResult.BadConversion -> throw conflict( - "Wrong currency conversion", - TalerErrorCode.BANK_BAD_CONVERSION - ) - CashoutCreationResult.AccountIsExchange -> throw conflict( - "Exchange account cannot perform cashout operation", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - CashoutCreationResult.BalanceInsufficient -> throw conflict( - "Insufficient funds to withdraw with Taler", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - CashoutCreationResult.RequestUidReuse -> throw conflict( - "request_uid used already", - TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED - ) - CashoutCreationResult.NoCashoutPayto -> throw conflict( - "Missing cashout payto uri", - TalerErrorCode.BANK_CONFIRM_INCOMPLETE - ) - CashoutCreationResult.TanRequired -> { - call.respondChallenge(db, Operation.cashout, req) - } - is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id)) - } - } - } - auth(db, TokenScope.readonly) { - get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") { - val id = call.longParameter("CASHOUT_ID") - val cashout = db.cashout.get(id, username) ?: throw notFound( - "Cashout operation $id not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - call.respond(cashout) - } - get("/accounts/{USERNAME}/cashouts") { - val params = PageParams.extract(call.request.queryParameters) - val cashouts = db.cashout.pageForUser(params, username) - if (cashouts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(Cashouts(cashouts)) - } - } - } - authAdmin(db, TokenScope.readonly) { - get("/cashouts") { - val params = PageParams.extract(call.request.queryParameters) - val cashouts = db.cashout.pageAll(params) - if (cashouts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(GlobalCashouts(cashouts)) - } - } - } -} - -private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readwrite) { - post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { - val id = call.longParameter("CHALLENGE_ID") - val res = db.tan.send( - id = id, - login = username, - code = Tan.genCode(), - now = Instant.now(), - retryCounter = TAN_RETRY_COUNTER, - validityPeriod = TAN_VALIDITY_PERIOD - ) - when (res) { - TanSendResult.NotFound -> throw notFound( - "Challenge $id not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - is TanSendResult.Success -> { - res.tanCode?.run { - val (tanScript, tanEnv) = ctx.tanChannels.get(res.tanChannel) - ?: throw unsupportedTanChannel(res.tanChannel) - val msg = "T-${res.tanCode} is your ${ctx.name} verification code" - val exitValue = withContext(Dispatchers.IO) { - val builder = ProcessBuilder(tanScript.toString(), res.tanInfo) - builder.redirectErrorStream(true) - for ((name, value) in tanEnv) { - builder.environment()[name] = value - } - val process = builder.start() - try { - process.outputWriter().use { it.write(msg) } - process.onExit().await() - } catch (e: Exception) { - process.destroy() - } - val exitValue = process.exitValue() - if (exitValue != 0) { - val out = runCatching { - process.getInputStream().use { - reader().readText() - } - }.getOrDefault("") - if (out.isNotEmpty()) { - logger.error("TAN ${res.tanChannel} - ${tanScript}: $out") - } - } - exitValue - } - if (exitValue != 0) { - throw libeufinError( - HttpStatusCode.BadGateway, - "Tan channel script failure with exit value $exitValue", - TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED - ) - } - db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD) - } - call.respond(TanTransmission( - tan_info = res.tanInfo, - tan_channel = res.tanChannel - )) - } - } - } - post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") { - val id = call.longParameter("CHALLENGE_ID") - val req = call.receive<ChallengeSolve>() - val code = req.tan.removePrefix("T-") - val res = db.tan.solve( - id = id, - login = username, - code = code, - now = Instant.now() - ) - when (res) { - TanSolveResult.NotFound -> throw notFound( - "Challenge $id not found", - TalerErrorCode.BANK_CHALLENGE_NOT_FOUND - ) - TanSolveResult.BadCode -> throw conflict( - "Incorrect TAN code", - TalerErrorCode.BANK_TAN_CHALLENGE_FAILED - ) - TanSolveResult.NoRetry -> throw libeufinError( - HttpStatusCode.TooManyRequests, - "Too many failed confirmation attempt", - TalerErrorCode.BANK_TAN_RATE_LIMITED - ) - TanSolveResult.Expired -> throw conflict( - "Challenge expired", - TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED - ) - is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } - } -} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -62,6 +62,7 @@ import java.util.zip.Inflater import kotlin.io.path.Path import kotlin.io.path.exists import kotlin.io.path.readText +import tech.libeufin.bank.api.* private val logger: Logger = LoggerFactory.getLogger("libeufin-bank") // Dirty local variable to stop the server in test TODO remove this ugly hack diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Params.kt b/bank/src/main/kotlin/tech/libeufin/bank/Params.kt @@ -1,163 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 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 - -import io.ktor.http.* -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.TalerErrorCode -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.temporal.TemporalAdjusters - -fun Parameters.expect(name: String): String - = get(name) ?: throw badRequest("Missing '$name' parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) -fun Parameters.int(name: String): Int? - = get(name)?.run { toIntOrNull() ?: throw badRequest("Param 'which' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) } -fun Parameters.expectInt(name: String): Int - = int(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) -fun Parameters.long(name: String): Long? - = get(name)?.run { toLongOrNull() ?: throw badRequest("Param 'which' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) } -fun Parameters.expectLong(name: String): Long - = long(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) -fun Parameters.amount(name: String): TalerAmount? - = get(name)?.run { - try { - TalerAmount(this) - } catch (e: Exception) { - throw badRequest("Param '$name' not a taler amount", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - } - } - -data class MonitorParams( - val timeframe: Timeframe, - val which: Int? -) { - companion object { - val names = Timeframe.entries.map { it.name } - val names_fmt = names.joinToString() - fun extract(params: Parameters): MonitorParams { - val raw = params.get("timeframe") ?: "hour" - if (!names.contains(raw)) { - throw badRequest("Param 'timeframe' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - } - val timeframe = Timeframe.valueOf(raw) - val which = params.int("which") - if (which != null) { - val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth - when { - timeframe == Timeframe.hour && (0 > which || which > 23) -> - throw badRequest("For hour timestamp param 'which' must be between 00 to 23", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - timeframe == Timeframe.day && (1 > which || which > lastDayOfMonth) -> - throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - timeframe == Timeframe.month && (1 > which || which > 12) -> - throw badRequest("For month timestamp param 'which' must be between 1 to 12", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - timeframe == Timeframe.year && (1 > which|| which > 9999) -> - throw badRequest("For year timestamp param 'which' must be between 0001 to 9999", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - else -> {} - } - } - return MonitorParams(timeframe, which) - } - } -} - -data class AccountParams( - val page: PageParams, val loginFilter: String -) { - companion object { - fun extract(params: Parameters): AccountParams { - val loginFilter = params.get("filter_name")?.run { "%$this%" } ?: "%" - return AccountParams(PageParams.extract(params), loginFilter) - } - } -} - -data class PageParams( - val delta: Int, val start: Long -) { - companion object { - fun extract(params: Parameters): PageParams { - val delta: Int = params.int("delta") ?: -20 - val start: Long = params.long("start") ?: if (delta >= 0) 0L else Long.MAX_VALUE - if (start < 0) throw badRequest("Param 'start' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - // TODO enforce delta limit - return PageParams(delta, start) - } - } -} - -data class PollingParams( - val poll_ms: Long -) { - companion object { - fun extract(params: Parameters): PollingParams { - val poll_ms: Long = params.long("long_poll_ms") ?: 0 - if (poll_ms < 0) throw badRequest("Param 'long_poll_ms' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - return PollingParams(poll_ms) - } - } -} - -data class HistoryParams( - val page: PageParams, val polling: PollingParams -) { - companion object { - fun extract(params: Parameters): HistoryParams { - return HistoryParams(PageParams.extract(params), PollingParams.extract(params)) - } - } -} - -data class RateParams( - val debit: TalerAmount?, val credit: TalerAmount? -) { - companion object { - fun extract(params: Parameters): RateParams { - val debit = params.amount("amount_debit") - val credit = params.amount("amount_credit") - if (debit == null && credit == null) { - throw badRequest("Either param 'amount_debit' or 'amount_credit' is required", TalerErrorCode.GENERIC_PARAMETER_MISSING) - } else if (debit != null && credit != null) { - throw badRequest("Cannot have both 'amount_debit' and 'amount_credit' params", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - } - return RateParams(debit, credit) - } - } -} - -data class StatusParams( - val polling: PollingParams, - val old_state: WithdrawalStatus -) { - companion object { - val names = WithdrawalStatus.entries.map { it.name } - val names_fmt = names.joinToString() - fun extract(params: Parameters): StatusParams { - val old_state = params.get("old_state") ?: "pending" - if (!names.contains(old_state)) { - throw badRequest("Param 'old_state' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - } - return StatusParams( - polling = PollingParams.extract(params), - old_state = WithdrawalStatus.valueOf(old_state) - ) - } - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt @@ -1,47 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 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 - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import tech.libeufin.bank.auth.auth -import tech.libeufin.bank.db.Database - -fun Routing.revenueApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readonly) { - get("/accounts/{USERNAME}/taler-revenue/config") { - call.respond(RevenueConfig( - currency = ctx.regionalCurrency - )) - } - get("/accounts/{USERNAME}/taler-revenue/history") { - val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankInfo(db, ctx.payto) - val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId, ctx.payto) - - if (items.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(RevenueIncomingHistory(items, bankAccount.payto)) - } - } - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -1,155 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 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/> - */ - -// This file contains the Taler Wire Gateway API handlers. - -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.util.pipeline.* -import tech.libeufin.bank.auth.auth -import tech.libeufin.bank.auth.authAdmin -import tech.libeufin.bank.auth.username -import tech.libeufin.bank.db.Database -import tech.libeufin.bank.db.ExchangeDAO -import tech.libeufin.bank.db.ExchangeDAO.AddIncomingResult -import tech.libeufin.bank.db.ExchangeDAO.TransferResult -import tech.libeufin.common.BankPaytoCtx -import tech.libeufin.common.TalerErrorCode -import java.time.Instant - - -fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readwrite) { - get("/accounts/{USERNAME}/taler-wire-gateway/config") { - call.respond(WireGatewayConfig( - currency = ctx.regionalCurrency - )) - } - post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { - val req = call.receive<TransferRequest>() - ctx.checkRegionalCurrency(req.amount) - val res = db.exchange.transfer( - req = req, - login = username, - now = Instant.now() - ) - when (res) { - is TransferResult.UnknownExchange -> throw unknownAccount(username) - is TransferResult.NotAnExchange -> throw conflict( - "$username is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical) - is TransferResult.BothPartyAreExchange -> throw conflict( - "Wire transfer attempted with credit and debit party being both exchange account", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - is TransferResult.ReserveUidReuse -> throw conflict( - "request_uid used already", - TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED - ) - is TransferResult.BalanceInsufficient -> throw conflict( - "Insufficient balance for exchange", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - is TransferResult.Success -> call.respond( - TransferResponse( - timestamp = res.timestamp, - row_id = res.id - ) - ) - } - } - } - auth(db, TokenScope.readonly) { - suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( - reduce: (List<T>, String) -> Any, - dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> - ) { - val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankInfo(db, ctx.payto) - - if (!bankAccount.isTalerExchange) - throw conflict( - "$username is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - - val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto) - - if (items.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(reduce(items, bankAccount.payto)) - } - } - get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { - historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) - } - get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") { - historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) - } - } - authAdmin(db, TokenScope.readwrite) { - post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - val req = call.receive<AddIncomingRequest>() - ctx.checkRegionalCurrency(req.amount) - val timestamp = Instant.now() - val res = db.exchange.addIncoming( - req = req, - login = username, - now = timestamp - ) - when (res) { - is AddIncomingResult.UnknownExchange -> throw unknownAccount(username) - is AddIncomingResult.NotAnExchange -> throw conflict( - "$username is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - is AddIncomingResult.UnknownDebtor -> throw conflict( - "Debtor account ${req.debit_account} was not found", - TalerErrorCode.BANK_UNKNOWN_DEBTOR - ) - is AddIncomingResult.BothPartyAreExchange -> throw conflict( - "Wire transfer attempted with credit and debit party being both exchange account", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - is AddIncomingResult.ReservePubReuse -> throw conflict( - "reserve_pub used already", - TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT - ) - is AddIncomingResult.BalanceInsufficient -> throw conflict( - "Insufficient balance for debitor", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - is AddIncomingResult.Success -> call.respond( - AddIncomingResponse( - timestamp = TalerProtocolTimestamp(timestamp), - row_id = res.id - ) - ) - } - } - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -0,0 +1,107 @@ +/* + * 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/> + */ + +/* This file contains the Taler Integration API endpoints, +* that are typically requested by wallets. */ +package tech.libeufin.bank.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.bank.* +import tech.libeufin.bank.db.AbortResult +import tech.libeufin.bank.db.Database +import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult +import tech.libeufin.common.TalerErrorCode + +fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { + get("/taler-integration/config") { + call.respond(TalerIntegrationConfigResponse( + currency = ctx.regionalCurrency, + currency_specification = ctx.regionalCurrencySpec + )) + } + + // Note: wopid acts as an authentication token. + get("/taler-integration/withdrawal-operation/{wopid}") { + val uuid = call.uuidPath("wopid") + val params = StatusParams.extract(call.request.queryParameters) + val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod) ?: throw notFound( + "Withdrawal operation '$uuid' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + call.respond(op.copy( + suggested_exchange = ctx.suggestedWithdrawalExchange, + confirm_transfer_url = if (op.status == WithdrawalStatus.pending || op.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null + )) + } + post("/taler-integration/withdrawal-operation/{wopid}") { + val uuid = call.uuidPath("wopid") + val req = call.receive<BankWithdrawalOperationPostRequest>() + + val res = db.withdrawal.setDetails( + uuid, req.selected_exchange, req.reserve_pub + ) + when (res) { + is WithdrawalSelectionResult.UnknownOperation -> throw notFound( + "Withdrawal operation '$uuid' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + is WithdrawalSelectionResult.AlreadySelected -> throw conflict( + "Cannot select different exchange and reserve pub. under the same withdrawal operation", + TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT + ) + is WithdrawalSelectionResult.RequestPubReuse -> throw conflict( + "Reserve pub. already used", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + is WithdrawalSelectionResult.UnknownAccount -> throw conflict( + "Account ${req.selected_exchange} not found", + TalerErrorCode.BANK_UNKNOWN_ACCOUNT + ) + is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict( + "Account ${req.selected_exchange} is not an exchange", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE + ) + is WithdrawalSelectionResult.Success -> { + call.respond(BankWithdrawalOperationPostResponse( + transfer_done = res.status == WithdrawalStatus.confirmed, + status = res.status, + confirm_transfer_url = if (res.status == WithdrawalStatus.pending || res.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null + )) + } + } + } + post("/taler-integration/withdrawal-operation/{wopid}/abort") { + val uuid = call.uuidPath("wopid") + when (db.withdrawal.abort(uuid)) { + AbortResult.UnknownOperation -> throw notFound( + "Withdrawal operation '$uuid' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + AbortResult.AlreadyConfirmed -> throw conflict( + "Cannot abort confirmed withdrawal", + TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT + ) + AbortResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -0,0 +1,117 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-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.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.bank.* +import tech.libeufin.bank.auth.authAdmin +import tech.libeufin.bank.db.ConversionDAO +import tech.libeufin.bank.db.ConversionDAO.ConversionResult +import tech.libeufin.bank.db.Database +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.TalerErrorCode + +fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { + get("/conversion-info/config") { + val config = db.conversion.getConfig(ctx.regionalCurrency, ctx.fiatCurrency!!) + if (config == null) { + throw libeufinError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + } + call.respond( + ConversionConfig( + regional_currency = ctx.regionalCurrency, + regional_currency_specification = ctx.regionalCurrencySpec, + fiat_currency = ctx.fiatCurrency, + fiat_currency_specification = ctx.fiatCurrencySpec!!, + conversion_rate = config + ) + ) + } + suspend fun ApplicationCall.convert( + input: TalerAmount, + conversion: suspend ConversionDAO.(TalerAmount) -> ConversionResult, + output: (TalerAmount) -> ConversionResponse + ) { + when (val res = db.conversion.(conversion)(input)) { + is ConversionResult.Success -> respond(output(res.converted)) + is ConversionResult.ToSmall -> throw conflict( + "$input is too small to be converted", + TalerErrorCode.BANK_BAD_CONVERSION + ) + is ConversionResult.MissingConfig -> throw libeufinError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + } + } + get("/conversion-info/cashout-rate") { + val params = RateParams.extract(call.request.queryParameters) + + params.debit?.let { ctx.checkRegionalCurrency(it) } + params.credit?.let { ctx.checkFiatCurrency(it) } + + if (params.debit != null) { + call.convert(params.debit, ConversionDAO::toCashout) { + ConversionResponse(params.debit, it) + } + } else { + call.convert(params.credit!!, ConversionDAO::fromCashout) { + ConversionResponse(it, params.credit) + } + } + } + get("/conversion-info/cashin-rate") { + val params = RateParams.extract(call.request.queryParameters) + + params.debit?.let { ctx.checkFiatCurrency(it) } + params.credit?.let { ctx.checkRegionalCurrency(it) } + + if (params.debit != null) { + call.convert(params.debit, ConversionDAO::toCashin) { + ConversionResponse(params.debit, it) + } + } else { + call.convert(params.credit!!, ConversionDAO::fromCashin) { + ConversionResponse(it, params.credit) + } + } + } + authAdmin(db, TokenScope.readwrite) { + 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)) { + ctx.checkRegionalCurrency(regionalAmount) + } + for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) { + ctx.checkFiatCurrency(fiatAmount) + } + db.conversion.updateConfig(req) + call.respond(HttpStatusCode.NoContent) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -0,0 +1,743 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-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.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withContext +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.bank.auth.* +import tech.libeufin.bank.db.AbortResult +import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult +import tech.libeufin.bank.db.Database +import tech.libeufin.bank.db.TanDAO.TanSendResult +import tech.libeufin.bank.db.TanDAO.TanSolveResult +import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult +import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult +import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult +import tech.libeufin.common.* +import tech.libeufin.bank.* +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api") + +fun Routing.coreBankApi(db: Database, ctx: BankConfig) { + get("/config") { + call.respond( + Config( + bank_name = ctx.name, + currency = ctx.regionalCurrency, + currency_specification = ctx.regionalCurrencySpec, + allow_conversion = ctx.allowConversion, + allow_registrations = ctx.allowRegistration, + allow_deletions = ctx.allowAccountDeletion, + default_debit_threshold = ctx.defaultDebtLimit, + supported_tan_channels = ctx.tanChannels.keys, + allow_edit_name = ctx.allowEditName, + allow_edit_cashout_payto_uri = ctx.allowEditCashout, + wire_type = ctx.wireMethod + ) + ) + } + authAdmin(db, TokenScope.readonly) { + get("/monitor") { + val params = MonitorParams.extract(call.request.queryParameters) + call.respond(db.monitor(params)) + } + } + coreBankTokenApi(db) + coreBankAccountsApi(db, ctx) + coreBankTransactionsApi(db, ctx) + coreBankWithdrawalApi(db, ctx) + coreBankCashoutApi(db, ctx) + coreBankTanApi(db, ctx) +} + +private fun Routing.coreBankTokenApi(db: Database) { + val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) + auth(db, TokenScope.refreshable) { + post("/accounts/{USERNAME}/token") { + val existingToken = call.authToken + val req = call.receive<TokenRequest>() + + if (existingToken != null) { + // This block checks permissions ONLY IF the call was authenticated with a token + val refreshingToken = db.token.get(existingToken) ?: throw internalServerError( + "Token used to auth not found in the database!" + ) + if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) + throw forbidden( + "Cannot generate RW token from RO", + TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT + ) + } + val token = Base32Crockford32B.rand() + val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION + + val creationTime = Instant.now() + val expirationTimestamp = + if (tokenDuration == ChronoUnit.FOREVER.duration) { + logger.debug("Creating 'forever' token.") + Instant.MAX + } else { + try { + logger.debug("Creating token with days duration: ${tokenDuration.toDays()}") + creationTime.plus(tokenDuration) + } catch (e: Exception) { + throw badRequest("Bad token duration: ${e.message}") + } + } + if (!db.token.create( + login = username, + content = token.raw, + creationTime = creationTime, + expirationTime = expirationTimestamp, + scope = req.scope, + isRefreshable = req.refreshable + )) { + throw internalServerError("Failed at inserting new token in the database") + } + call.respond( + TokenSuccessResponse( + access_token = token.encoded(), + expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) + ) + ) + } + } + auth(db, TokenScope.readonly) { + delete("/accounts/{USERNAME}/token") { + val token = call.authToken ?: throw badRequest("Basic auth not supported here.") + db.token.delete(token) + call.respond(HttpStatusCode.NoContent) + } + } +} + +suspend fun createAccount( + db: Database, + cfg: BankConfig, + req: RegisterAccountRequest, + isAdmin: Boolean +): Pair<AccountCreationResult, String> { + // Prohibit reserved usernames: + if (RESERVED_ACCOUNTS.contains(req.username)) + throw conflict( + "Username '${req.username}' is reserved", + TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT + ) + + if (!isAdmin) { + if (req.debit_threshold != null) + throw conflict( + "only admin account can choose the debit limit", + TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT + ) + + if (req.tan_channel != null) + throw conflict( + "only admin account can enable 2fa on creation", + TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL + ) + + } else if (req.tan_channel != null) { + if (cfg.tanChannels.get(req.tan_channel) == null) { + throw unsupportedTanChannel(req.tan_channel) + } + val missing = when (req.tan_channel) { + TanChannel.sms -> req.contact_data?.phone?.get() == null + TanChannel.email -> req.contact_data?.email?.get() == null + } + if (missing) + throw conflict( + "missing info for tan channel ${req.tan_channel}", + TalerErrorCode.BANK_MISSING_TAN_INFO + ) + } + + if (req.username == "exchange" && !req.is_taler_exchange) + throw conflict( + "'exchange' account must be a taler exchange account", + TalerErrorCode.END + ) + + when (cfg.wireMethod) { + WireMethod.IBAN -> { + if (req.payto_uri != null && !(req.payto_uri is IbanPayto)) + throw badRequest("Expected an IBAN payto uri") + var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0 + + while (true) { + val internalPayto = req.payto_uri ?: IbanPayto.rand() as Payto + val res = db.account.create( + login = req.username, + name = req.name, + email = req.contact_data?.email?.get(), + phone = req.contact_data?.phone?.get(), + cashoutPayto = req.cashout_payto_uri, + password = req.password, + internalPayto = internalPayto, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, + bonus = if (!req.is_taler_exchange) cfg.registrationBonus + else TalerAmount(0, 0, cfg.regionalCurrency), + tanChannel = req.tan_channel, + checkPaytoIdempotent = req.payto_uri != null + ) + // Retry with new IBAN + if (res == AccountCreationResult.PayToReuse && retry > 0) { + retry-- + continue + } + return Pair(res, internalPayto.bank(req.name, cfg.payto)) + } + } + WireMethod.X_TALER_BANK -> { + if (req.payto_uri != null) { + if (!(req.payto_uri is XTalerBankPayto)) + throw badRequest("Expected an IBAN payto uri") + else if (req.payto_uri.username != req.username) + throw badRequest("Expected a payto uri for '${req.username}' got one for '${req.payto_uri.username}'") + } + + val internalPayto = XTalerBankPayto.forUsername(req.username) + + val res = db.account.create( + login = req.username, + name = req.name, + email = req.contact_data?.email?.get(), + phone = req.contact_data?.phone?.get(), + cashoutPayto = req.cashout_payto_uri, + password = req.password, + internalPayto = internalPayto, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, + bonus = if (!req.is_taler_exchange) cfg.registrationBonus + else TalerAmount(0, 0, cfg.regionalCurrency), + tanChannel = req.tan_channel, + checkPaytoIdempotent = req.payto_uri != null + ) + return Pair(res, internalPayto.bank(req.name, cfg.payto)) + } + } +} + +suspend fun patchAccount( + db: Database, + cfg: BankConfig, + req: AccountReconfiguration, + username: String, + isAdmin: Boolean, + is2fa: Boolean, + channel: TanChannel? = null, + info: String? = null +): AccountPatchResult { + req.debit_threshold?.run { cfg.checkRegionalCurrency(this) } + + if (username == "admin" && req.is_public == true) + throw conflict( + "'admin' account cannot be public", + TalerErrorCode.END + ) + + if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) { + throw unsupportedTanChannel(req.tan_channel.value) + } + + return db.account.reconfig( + login = username, + name = req.name, + cashoutPayto = req.cashout_payto_uri, + email = req.contact_data?.email ?: Option.None, + phone = req.contact_data?.phone ?: Option.None, + tan_channel = req.tan_channel, + isPublic = req.is_public, + debtLimit = req.debit_threshold, + isAdmin = isAdmin, + is2fa = is2fa, + faChannel = channel, + faInfo = info, + allowEditName = cfg.allowEditName, + allowEditCashout = cfg.allowEditCashout + ) +} + +private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { + authAdmin(db, TokenScope.readwrite, !ctx.allowRegistration) { + post("/accounts") { + val req = call.receive<RegisterAccountRequest>() + val (result, internalPayto) = createAccount(db, ctx, req, isAdmin) + when (result) { + AccountCreationResult.BonusBalanceInsufficient -> throw conflict( + "Insufficient admin funds to grant bonus", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + AccountCreationResult.LoginReuse -> throw conflict( + "Account username reuse '${req.username}'", + TalerErrorCode.BANK_REGISTER_USERNAME_REUSE + ) + AccountCreationResult.PayToReuse -> throw conflict( + "Bank internalPayToUri reuse '$internalPayto'", + TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE + ) + AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto)) + } + } + } + auth( + db, + TokenScope.readwrite, + allowAdmin = true, + requireAdmin = !ctx.allowAccountDeletion + ) { + delete("/accounts/{USERNAME}") { + val challenge = call.checkChallenge(db, Operation.account_delete) + + // Not deleting reserved names. + if (RESERVED_ACCOUNTS.contains(username)) + throw conflict( + "Cannot delete reserved accounts", + TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT + ) + if (username == "exchange" && ctx.allowConversion) + throw conflict( + "Cannot delete 'exchange' accounts when conversion is enabled", + TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT + ) + + when (db.account.delete(username, isAdmin || challenge != null)) { + AccountDeletionResult.UnknownAccount -> throw unknownAccount(username) + AccountDeletionResult.BalanceNotZero -> throw conflict( + "Account balance is not zero.", + TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO + ) + AccountDeletionResult.TanRequired -> call.respondChallenge(db, Operation.account_delete, Unit) + AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + } + auth(db, TokenScope.readwrite, allowAdmin = true) { + patch("/accounts/{USERNAME}") { + val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig) + val res = patchAccount(db, ctx, req, username, 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(username) + AccountPatchResult.NonAdminName -> throw conflict( + "non-admin user cannot change their legal name", + TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME + ) + AccountPatchResult.NonAdminCashout -> throw conflict( + "non-admin user cannot change their cashout account", + TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT + ) + AccountPatchResult.NonAdminDebtLimit -> throw conflict( + "non-admin user cannot change their debt limit", + TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT + ) + AccountPatchResult.MissingTanInfo -> throw conflict( + "missing info for tan channel ${req.tan_channel.get()}", + TalerErrorCode.BANK_MISSING_TAN_INFO + ) + } + } + patch("/accounts/{USERNAME}/auth") { + val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig) + + if (!isAdmin && req.old_password == null) { + throw conflict( + "non-admin user cannot change password without providing old password", + TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD + ) + } + when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null)) { + AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent) + AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req) + AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(username) + AccountPatchAuthResult.OldPasswordMismatch -> throw conflict( + "old password does not match", + TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD + ) + } + } + } + get("/public-accounts") { + val params = AccountParams.extract(call.request.queryParameters) + val publicAccounts = db.account.pagePublic(params, ctx.payto) + if (publicAccounts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(PublicAccountsResponse(publicAccounts)) + } + } + authAdmin(db, TokenScope.readonly) { + get("/accounts") { + val params = AccountParams.extract(call.request.queryParameters) + val accounts = db.account.pageAdmin(params, ctx.payto) + if (accounts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(ListBankAccountsResponse(accounts)) + } + } + } + auth(db, TokenScope.readonly, allowAdmin = true) { + get("/accounts/{USERNAME}") { + val account = db.account.get(username, ctx.payto) ?: throw unknownAccount(username) + call.respond(account) + } + } +} + +private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { + auth(db, TokenScope.readonly) { + get("/accounts/{USERNAME}/transactions") { + val params = HistoryParams.extract(call.request.queryParameters) + val bankAccount = call.bankInfo(db, ctx.payto) + + val history: List<BankAccountTransactionInfo> = + db.transaction.pollHistory(params, bankAccount.bankAccountId, ctx.payto) + if (history.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(BankAccountTransactionsResponse(history)) + } + } + get("/accounts/{USERNAME}/transactions/{T_ID}") { + val tId = call.longPath("T_ID") + val tx = db.transaction.get(tId, username, ctx.payto) ?: throw notFound( + "Bank transaction '$tId' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + call.respond(tx) + } + } + auth(db, TokenScope.readwrite) { + post("/accounts/{USERNAME}/transactions") { + val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction) + + val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") + val amount = req.payto_uri.amount ?: req.amount ?: throw badRequest("Wire transfer lacks amount") + + ctx.checkRegionalCurrency(amount) + + val res = db.transaction.create( + creditAccountPayto = req.payto_uri, + debitAccountUsername = username, + subject = subject, + amount = amount, + timestamp = Instant.now(), + is2fa = challenge != null + ) + when (res) { + BankTransactionResult.UnknownDebtor -> throw unknownAccount(username) + BankTransactionResult.TanRequired -> { + call.respondChallenge(db, Operation.bank_transaction, req) + } + BankTransactionResult.BothPartySame -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.BANK_SAME_ACCOUNT + ) + BankTransactionResult.UnknownCreditor -> throw unknownCreditorAccount(req.payto_uri.canonical) + BankTransactionResult.AdminCreditor -> throw conflict( + "Cannot transfer money to admin account", + TalerErrorCode.BANK_ADMIN_CREDITOR + ) + BankTransactionResult.BalanceInsufficient -> throw conflict( + "Insufficient funds", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id)) + } + } + } +} + +private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { + auth(db, TokenScope.readwrite) { + post("/accounts/{USERNAME}/withdrawals") { + val req = call.receive<BankAccountCreateWithdrawalRequest>() + ctx.checkRegionalCurrency(req.amount) + val opId = UUID.randomUUID() + when (db.withdrawal.create(username, opId, req.amount, Instant.now())) { + WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username) + WithdrawalCreationResult.AccountIsExchange -> throw conflict( + "Exchange account cannot perform withdrawal operation", + TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE + ) + WithdrawalCreationResult.BalanceInsufficient -> throw conflict( + "Insufficient funds to withdraw with Taler", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + WithdrawalCreationResult.Success -> { + call.respond( + BankAccountCreateWithdrawalResponse( + withdrawal_id = opId.toString(), + taler_withdraw_uri = call.request.talerWithdrawUri(opId) + ) + ) + } + } + } + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { + val id = call.uuidPath("withdrawal_id") + val challenge = call.checkChallenge(db, Operation.withdrawal) + when (db.withdrawal.confirm(username, id, Instant.now(), challenge != null)) { + WithdrawalConfirmationResult.UnknownOperation -> throw notFound( + "Withdrawal operation $id not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + WithdrawalConfirmationResult.AlreadyAborted -> throw conflict( + "Cannot confirm an aborted withdrawal", + TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT + ) + WithdrawalConfirmationResult.NotSelected -> throw conflict( + "Cannot confirm an unselected withdrawal", + TalerErrorCode.BANK_CONFIRM_INCOMPLETE + ) + WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict( + "Insufficient funds", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + WithdrawalConfirmationResult.UnknownExchange -> throw conflict( + "Exchange to withdraw from not found", + TalerErrorCode.BANK_UNKNOWN_CREDITOR + ) + WithdrawalConfirmationResult.TanRequired -> { + call.respondChallenge(db, Operation.withdrawal, StoredUUID(id)) + } + WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { + val opId = call.uuidPath("withdrawal_id") + when (db.withdrawal.abort(opId)) { + AbortResult.UnknownOperation -> throw notFound( + "Withdrawal operation $opId not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + AbortResult.AlreadyConfirmed -> throw conflict( + "Cannot abort confirmed withdrawal", + TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT + ) + AbortResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + } + get("/withdrawals/{withdrawal_id}") { + val uuid = call.uuidPath("withdrawal_id") + val params = StatusParams.extract(call.request.queryParameters) + val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound( + "Withdrawal operation '$uuid' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + call.respond(op) + } +} + +private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { + auth(db, TokenScope.readwrite) { + post("/accounts/{USERNAME}/cashouts") { + val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout) + + ctx.checkRegionalCurrency(req.amount_debit) + ctx.checkFiatCurrency(req.amount_credit) + + val res = db.cashout.create( + login = username, + requestUid = req.request_uid, + amountDebit = req.amount_debit, + amountCredit = req.amount_credit, + subject = req.subject ?: "", // TODO default subject + now = Instant.now(), + is2fa = challenge != null + ) + when (res) { + CashoutCreationResult.AccountNotFound -> throw unknownAccount(username) + CashoutCreationResult.BadConversion -> throw conflict( + "Wrong currency conversion", + TalerErrorCode.BANK_BAD_CONVERSION + ) + CashoutCreationResult.AccountIsExchange -> throw conflict( + "Exchange account cannot perform cashout operation", + TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE + ) + CashoutCreationResult.BalanceInsufficient -> throw conflict( + "Insufficient funds to withdraw with Taler", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + CashoutCreationResult.RequestUidReuse -> throw conflict( + "request_uid used already", + TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED + ) + CashoutCreationResult.NoCashoutPayto -> throw conflict( + "Missing cashout payto uri", + TalerErrorCode.BANK_CONFIRM_INCOMPLETE + ) + CashoutCreationResult.TanRequired -> { + call.respondChallenge(db, Operation.cashout, req) + } + is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id)) + } + } + } + auth(db, TokenScope.readonly) { + get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") { + val id = call.longPath("CASHOUT_ID") + val cashout = db.cashout.get(id, username) ?: throw notFound( + "Cashout operation $id not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + call.respond(cashout) + } + get("/accounts/{USERNAME}/cashouts") { + val params = PageParams.extract(call.request.queryParameters) + val cashouts = db.cashout.pageForUser(params, username) + if (cashouts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(Cashouts(cashouts)) + } + } + } + authAdmin(db, TokenScope.readonly) { + get("/cashouts") { + val params = PageParams.extract(call.request.queryParameters) + val cashouts = db.cashout.pageAll(params) + if (cashouts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(GlobalCashouts(cashouts)) + } + } + } +} + +private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { + auth(db, TokenScope.readwrite) { + post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { + val id = call.longPath("CHALLENGE_ID") + val res = db.tan.send( + id = id, + login = username, + code = Tan.genCode(), + now = Instant.now(), + retryCounter = TAN_RETRY_COUNTER, + validityPeriod = TAN_VALIDITY_PERIOD + ) + when (res) { + TanSendResult.NotFound -> throw notFound( + "Challenge $id not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + is TanSendResult.Success -> { + res.tanCode?.run { + val (tanScript, tanEnv) = ctx.tanChannels.get(res.tanChannel) + ?: throw unsupportedTanChannel(res.tanChannel) + val msg = "T-${res.tanCode} is your ${ctx.name} verification code" + val exitValue = withContext(Dispatchers.IO) { + val builder = ProcessBuilder(tanScript.toString(), res.tanInfo) + builder.redirectErrorStream(true) + for ((name, value) in tanEnv) { + builder.environment()[name] = value + } + val process = builder.start() + try { + process.outputWriter().use { it.write(msg) } + process.onExit().await() + } catch (e: Exception) { + process.destroy() + } + val exitValue = process.exitValue() + if (exitValue != 0) { + val out = runCatching { + process.getInputStream().use { + reader().readText() + } + }.getOrDefault("") + if (out.isNotEmpty()) { + logger.error("TAN ${res.tanChannel} - ${tanScript}: $out") + } + } + exitValue + } + if (exitValue != 0) { + throw libeufinError( + HttpStatusCode.BadGateway, + "Tan channel script failure with exit value $exitValue", + TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED + ) + } + db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD) + } + call.respond(TanTransmission( + tan_info = res.tanInfo, + tan_channel = res.tanChannel + )) + } + } + } + post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") { + val id = call.longPath("CHALLENGE_ID") + val req = call.receive<ChallengeSolve>() + val code = req.tan.removePrefix("T-") + val res = db.tan.solve( + id = id, + login = username, + code = code, + now = Instant.now() + ) + when (res) { + TanSolveResult.NotFound -> throw notFound( + "Challenge $id not found", + TalerErrorCode.BANK_CHALLENGE_NOT_FOUND + ) + TanSolveResult.BadCode -> throw conflict( + "Incorrect TAN code", + TalerErrorCode.BANK_TAN_CHALLENGE_FAILED + ) + TanSolveResult.NoRetry -> throw libeufinError( + HttpStatusCode.TooManyRequests, + "Too many failed confirmation attempt", + TalerErrorCode.BANK_TAN_RATE_LIMITED + ) + TanSolveResult.Expired -> throw conflict( + "Challenge expired", + TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED + ) + is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + } +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt @@ -0,0 +1,48 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-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.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.bank.* +import tech.libeufin.bank.auth.auth +import tech.libeufin.bank.db.Database + +fun Routing.revenueApi(db: Database, ctx: BankConfig) { + auth(db, TokenScope.readonly) { + get("/accounts/{USERNAME}/taler-revenue/config") { + call.respond(RevenueConfig( + currency = ctx.regionalCurrency + )) + } + get("/accounts/{USERNAME}/taler-revenue/history") { + val params = HistoryParams.extract(context.request.queryParameters) + val bankAccount = call.bankInfo(db, ctx.payto) + val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId, ctx.payto) + + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(RevenueIncomingHistory(items, bankAccount.payto)) + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -0,0 +1,156 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-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/> + */ + +// This file contains the Taler Wire Gateway API handlers. + +package tech.libeufin.bank.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +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.db.Database +import tech.libeufin.bank.db.ExchangeDAO +import tech.libeufin.bank.db.ExchangeDAO.AddIncomingResult +import tech.libeufin.bank.db.ExchangeDAO.TransferResult +import tech.libeufin.common.BankPaytoCtx +import tech.libeufin.common.TalerErrorCode +import java.time.Instant + + +fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { + auth(db, TokenScope.readwrite) { + get("/accounts/{USERNAME}/taler-wire-gateway/config") { + call.respond(WireGatewayConfig( + currency = ctx.regionalCurrency + )) + } + post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { + val req = call.receive<TransferRequest>() + ctx.checkRegionalCurrency(req.amount) + val res = db.exchange.transfer( + req = req, + login = username, + now = Instant.now() + ) + when (res) { + is TransferResult.UnknownExchange -> throw unknownAccount(username) + is TransferResult.NotAnExchange -> throw conflict( + "$username is not an exchange account.", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE + ) + is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical) + is TransferResult.BothPartyAreExchange -> throw conflict( + "Wire transfer attempted with credit and debit party being both exchange account", + TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE + ) + is TransferResult.ReserveUidReuse -> throw conflict( + "request_uid used already", + TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED + ) + is TransferResult.BalanceInsufficient -> throw conflict( + "Insufficient balance for exchange", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + is TransferResult.Success -> call.respond( + TransferResponse( + timestamp = res.timestamp, + row_id = res.id + ) + ) + } + } + } + auth(db, TokenScope.readonly) { + suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( + reduce: (List<T>, String) -> Any, + dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> + ) { + val params = HistoryParams.extract(context.request.queryParameters) + val bankAccount = call.bankInfo(db, ctx.payto) + + if (!bankAccount.isTalerExchange) + throw conflict( + "$username is not an exchange account.", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE + ) + + val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto) + + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(reduce(items, bankAccount.payto)) + } + } + get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { + historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) + } + get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") { + historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) + } + } + authAdmin(db, TokenScope.readwrite) { + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { + val req = call.receive<AddIncomingRequest>() + ctx.checkRegionalCurrency(req.amount) + val timestamp = Instant.now() + val res = db.exchange.addIncoming( + req = req, + login = username, + now = timestamp + ) + when (res) { + is AddIncomingResult.UnknownExchange -> throw unknownAccount(username) + is AddIncomingResult.NotAnExchange -> throw conflict( + "$username is not an exchange account.", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE + ) + is AddIncomingResult.UnknownDebtor -> throw conflict( + "Debtor account ${req.debit_account} was not found", + TalerErrorCode.BANK_UNKNOWN_DEBTOR + ) + is AddIncomingResult.BothPartyAreExchange -> throw conflict( + "Wire transfer attempted with credit and debit party being both exchange account", + TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE + ) + is AddIncomingResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + is AddIncomingResult.BalanceInsufficient -> throw conflict( + "Insufficient balance for debitor", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + is AddIncomingResult.Success -> call.respond( + AddIncomingResponse( + timestamp = TalerProtocolTimestamp(timestamp), + row_id = res.id + ) + ) + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -38,7 +38,7 @@ private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin") private val AUTH_TOKEN = AttributeKey<ByteArray>("auth_token") /** Get username of the request account */ -val ApplicationCall.username: String get() = expectParameter("USERNAME") +val ApplicationCall.username: String get() = parameters.expect("USERNAME") /** Get username of the request account */ val PipelineContext<Unit, ApplicationCall>.username: String get() = call.username diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -39,11 +39,23 @@ import tech.libeufin.bank.db.Database import tech.libeufin.common.* import java.util.* -fun ApplicationCall.expectParameter(name: String) = - parameters[name] ?: throw badRequest( - "Missing '$name' param", - TalerErrorCode.GENERIC_PARAMETER_MISSING - ) +fun ApplicationCall.uuidPath(name: String): UUID { + val value = parameters[name]!! + try { + return UUID.fromString(value) + } catch (e: Exception) { + throw badRequest("UUID uri component malformed: ${e.message}", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // TODO better error ? + } +} + +fun ApplicationCall.longPath(name: String): Long { + val value = parameters[name]!! + try { + return value.toLong() + } catch (e: Exception) { + throw badRequest("Long uri component malformed: ${e.message}", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // TODO better error ? + } +} /** Retrieve the bank account info for the selected username*/ suspend fun ApplicationCall.bankInfo(db: Database, ctx: BankPaytoCtx): BankInfo @@ -82,22 +94,6 @@ fun ApplicationRequest.withdrawConfirmUrl(id: UUID) = url { appendEncodedPathSegments("webui", "#", "operation", id.toString()) } -fun ApplicationCall.uuidParameter(name: String): UUID { - try { - return UUID.fromString(expectParameter(name)) - } catch (e: Exception) { - throw badRequest("UUID uri component malformed: ${e.message}") - } -} - -fun ApplicationCall.longParameter(name: String): Long { - try { - return expectParameter(name).toLong() - } catch (e: Exception) { - throw badRequest("Long uri component malformed: ${e.message}") - } -} - /** * This function creates the admin account ONLY IF it was * NOT found in the database. It sets it to a random password that diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt b/bank/src/main/kotlin/tech/libeufin/bank/params.kt @@ -0,0 +1,176 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 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 + +import io.ktor.http.* +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.TalerErrorCode +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.temporal.TemporalAdjusters +import java.util.UUID + +fun Parameters.expect(name: String): String + = get(name) ?: throw badRequest("Missing '$name' parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) +fun Parameters.int(name: String): Int? + = get(name)?.run { toIntOrNull() ?: throw badRequest("Param '$name' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) } +fun Parameters.expectInt(name: String): Int + = int(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) +fun Parameters.long(name: String): Long? + = get(name)?.run { toLongOrNull() ?: throw badRequest("Param '$name' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) } +fun Parameters.expectLong(name: String): Long + = long(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) +fun Parameters.uuid(name: String): UUID? { + return get(name)?.run { + try { + UUID.fromString(this) + } catch (e: Exception) { + println("$this $e") + throw badRequest("Param '$name' not an UUID", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + } + } +} +fun Parameters.expectUuid(name: String): UUID + = uuid(name) ?: throw badRequest("Missing '$name' UUID parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) +fun Parameters.amount(name: String): TalerAmount? + = get(name)?.run { + try { + TalerAmount(this) + } catch (e: Exception) { + throw badRequest("Param '$name' not a taler amount", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + } + } + +data class MonitorParams( + val timeframe: Timeframe, + val which: Int? +) { + companion object { + val names = Timeframe.entries.map { it.name } + val names_fmt = names.joinToString() + fun extract(params: Parameters): MonitorParams { + val raw = params.get("timeframe") ?: "hour" + if (!names.contains(raw)) { + throw badRequest("Param 'timeframe' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + } + val timeframe = Timeframe.valueOf(raw) + val which = params.int("which") + if (which != null) { + val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth + when { + timeframe == Timeframe.hour && (0 > which || which > 23) -> + throw badRequest("For hour timestamp param 'which' must be between 00 to 23", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + timeframe == Timeframe.day && (1 > which || which > lastDayOfMonth) -> + throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + timeframe == Timeframe.month && (1 > which || which > 12) -> + throw badRequest("For month timestamp param 'which' must be between 1 to 12", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + timeframe == Timeframe.year && (1 > which|| which > 9999) -> + throw badRequest("For year timestamp param 'which' must be between 0001 to 9999", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + else -> {} + } + } + return MonitorParams(timeframe, which) + } + } +} + +data class AccountParams( + val page: PageParams, val loginFilter: String +) { + companion object { + fun extract(params: Parameters): AccountParams { + val loginFilter = params.get("filter_name")?.run { "%$this%" } ?: "%" + return AccountParams(PageParams.extract(params), loginFilter) + } + } +} + +data class PageParams( + val delta: Int, val start: Long +) { + companion object { + fun extract(params: Parameters): PageParams { + val delta: Int = params.int("delta") ?: -20 + val start: Long = params.long("start") ?: if (delta >= 0) 0L else Long.MAX_VALUE + if (start < 0) throw badRequest("Param 'start' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + // TODO enforce delta limit + return PageParams(delta, start) + } + } +} + +data class PollingParams( + val poll_ms: Long +) { + companion object { + fun extract(params: Parameters): PollingParams { + val poll_ms: Long = params.long("long_poll_ms") ?: 0 + if (poll_ms < 0) throw badRequest("Param 'long_poll_ms' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + return PollingParams(poll_ms) + } + } +} + +data class HistoryParams( + val page: PageParams, val polling: PollingParams +) { + companion object { + fun extract(params: Parameters): HistoryParams { + return HistoryParams(PageParams.extract(params), PollingParams.extract(params)) + } + } +} + +data class RateParams( + val debit: TalerAmount?, val credit: TalerAmount? +) { + companion object { + fun extract(params: Parameters): RateParams { + val debit = params.amount("amount_debit") + val credit = params.amount("amount_credit") + if (debit == null && credit == null) { + throw badRequest("Either param 'amount_debit' or 'amount_credit' is required", TalerErrorCode.GENERIC_PARAMETER_MISSING) + } else if (debit != null && credit != null) { + throw badRequest("Cannot have both 'amount_debit' and 'amount_credit' params", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + } + return RateParams(debit, credit) + } + } +} + +data class StatusParams( + val polling: PollingParams, + val old_state: WithdrawalStatus +) { + companion object { + val names = WithdrawalStatus.entries.map { it.name } + val names_fmt = names.joinToString() + fun extract(params: Parameters): StatusParams { + val old_state = params.get("old_state") ?: "pending" + if (!names.contains(old_state)) { + throw badRequest("Param 'old_state' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + } + return StatusParams( + polling = PollingParams.extract(params), + old_state = WithdrawalStatus.valueOf(old_state) + ) + } + } +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -63,7 +63,7 @@ class BankIntegrationApiTest { // Check bad UUID client.get("/taler-integration/withdrawal-operation/chocolate") - .assertBadRequest() + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) } // POST /taler-integration/withdrawal-operation/UUID @@ -78,7 +78,7 @@ class BankIntegrationApiTest { // Check bad UUID client.post("/taler-integration/withdrawal-operation/chocolate") { json(req) - }.assertBadRequest() + }.assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.post("/taler-integration/withdrawal-operation/${UUID.randomUUID()}") { @@ -180,7 +180,8 @@ class BankIntegrationApiTest { } // Check bad UUID - client.postA("/taler-integration/withdrawal-operation/chocolate/abort").assertBadRequest() + client.postA("/taler-integration/withdrawal-operation/chocolate/abort") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.postA("/taler-integration/withdrawal-operation/${UUID.randomUUID()}/abort") diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1021,7 +1021,7 @@ class CoreBankWithdrawalApiTest { statusRoutine<WithdrawalPublicInfo>("/withdrawals") { it.status } // Check bad UUID - client.get("/withdrawals/chocolate").assertBadRequest() + client.get("/withdrawals/chocolate").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.get("/withdrawals/${UUID.randomUUID()}") @@ -1098,7 +1098,8 @@ class CoreBankWithdrawalApiTest { } // Check bad UUID - client.postA("/accounts/merchant/withdrawals/chocolate/confirm").assertBadRequest() + client.postA("/accounts/merchant/withdrawals/chocolate/confirm") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") @@ -1231,7 +1232,7 @@ class CoreBankCashoutApiTest { // Check bad UUID client.getA("/accounts/customer/cashouts/chocolate") - .assertBadRequest() + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.getA("/accounts/customer/cashouts/42")