diff options
Diffstat (limited to 'bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt')
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 743 |
1 files changed, 743 insertions, 0 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt new file mode 100644 index 00000000..feaa84df --- /dev/null +++ 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) + } + } + } +} |