libeufin

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

commit a8300c3edc9d9d19097e752e43f954a84428febb
parent 95a2a1dfabbb5c6f86adb5fb5244886370252c9a
Author: Antoine A <>
Date:   Wed,  3 Jan 2024 02:29:48 +0000

2fa for withdrawal

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 82+++++++++++++++++++++++++++++++++++--------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 3+--
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 13+++++++++----
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 24++++++++++++++++++++++--
Mbank/src/test/kotlin/CoreBankApiTest.kt | 16++++++++++++++++
Mdatabase-versioning/libeufin-bank-0002.sql | 2+-
Mdatabase-versioning/libeufin-bank-procedures.sql | 23+++++++++++++++++------
8 files changed, 104 insertions(+), 62 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -442,6 +442,36 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { } } +suspend fun ApplicationCall.confirmWithdrawalHttp(db: Database, ctx: BankConfig, id: UUID, is2fa: Boolean) { + when (db.withdrawal.confirm(id, Instant.now(), is2fa)) { + 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 -> { + respondChallenge(db, Operation.withdrawal, StoredUUID(id)) + } + WithdrawalConfirmationResult.Success -> respond(HttpStatusCode.NoContent) + } +} + + private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/withdrawals") { @@ -486,29 +516,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { val opId = call.uuidUriComponent("withdrawal_id") - when (db.withdrawal.confirm(opId, Instant.now())) { - WithdrawalConfirmationResult.UnknownOperation -> throw notFound( - "Withdrawal operation $opId 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.Success -> call.respond(HttpStatusCode.NoContent) - } + call.confirmWithdrawalHttp(db, ctx, opId, false) } } get("/withdrawals/{withdrawal_id}") { @@ -536,29 +544,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } post("/withdrawals/{withdrawal_id}/confirm") { val opId = call.uuidUriComponent("withdrawal_id") - when (db.withdrawal.confirm(opId, Instant.now())) { - WithdrawalConfirmationResult.UnknownOperation -> throw notFound( - "Withdrawal operation $opId 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.Success -> call.respond(HttpStatusCode.NoContent) - } + call.confirmWithdrawalHttp(db, ctx, opId, false) } } @@ -733,6 +719,10 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { val req = Json.decodeFromString<CashoutRequest>(res.body) call.cashoutHttp(db, ctx, req, true) } + Operation.withdrawal -> { + val req = Json.decodeFromString<StoredUUID>(res.body) + call.confirmWithdrawalHttp(db, ctx, req.value, true) + } } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -28,8 +28,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.* import java.util.concurrent.TimeUnit -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable +import kotlinx.serialization.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* import kotlinx.serialization.json.* diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -76,7 +76,8 @@ enum class Operation { account_reconfig, account_delete, bank_transaction, - cashout + cashout, + withdrawal } @Serializable(with = Option.Serializer::class) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -139,13 +139,15 @@ class WithdrawalDAO(private val db: Database) { UnknownExchange, BalanceInsufficient, NotSelected, - AlreadyAborted + AlreadyAborted, + TanRequired } /** Confirm withdrawal operation [uuid] */ suspend fun confirm( uuid: UUID, - now: Instant + now: Instant, + is2fa: Boolean ): WithdrawalConfirmationResult = db.serializable { conn -> // TODO login check val stmt = conn.prepareStatement(""" @@ -154,12 +156,14 @@ class WithdrawalDAO(private val db: Database) { out_exchange_not_found, out_balance_insufficient, out_not_selected, - out_aborted - FROM confirm_taler_withdrawal(?, ?); + out_aborted, + out_tan_required + FROM confirm_taler_withdrawal(?,?,?); """ ) stmt.setObject(1, uuid) stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setBoolean(3, is2fa) stmt.executeQuery().use { when { !it.next() -> @@ -169,6 +173,7 @@ class WithdrawalDAO(private val db: Database) { it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted + it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired else -> WithdrawalConfirmationResult.Success } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -29,6 +29,9 @@ import io.ktor.server.routing.RouteSelectorEvaluation import io.ktor.server.routing.RoutingResolveContext import io.ktor.server.util.* import io.ktor.util.pipeline.PipelineContext +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* import java.net.* import java.time.* import java.time.temporal.* @@ -153,4 +156,21 @@ fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route = if (!implemented) { throw libeufinError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) } - } -\ No newline at end of file + } + +@Serializable(with = StoredUUID.Serializer::class) +data class StoredUUID(val value: UUID) { + internal object Serializer : KSerializer<StoredUUID> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("StoredUUID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: StoredUUID) { + encoder.encodeString(value.value.toString()) + } + + override fun deserialize(decoder: Decoder): StoredUUID { + val string = decoder.decodeString() + return StoredUUID(UUID.fromString(string)) + } + } +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -961,6 +961,22 @@ class CoreBankWithdrawalApiTest { // Check unknown client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Check 2fa + fillTanInfo("merchant") + assertBalance("merchant", "-KUDOS:6") + client.postA("/accounts/merchant/withdrawals") { + json { "amount" to "KUDOS:1" } + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.taler_withdraw_uri.split("/").last() + withdrawalSelect(uuid) + + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") + .assertChallenge { _,_-> + assertBalance("merchant", "-KUDOS:6") + }.assertNoContent() + } + } } diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql @@ -32,7 +32,7 @@ ALTER TABLE customers ADD tan_channel tan_enum NULL; CREATE TYPE op_enum - AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction', 'cashout'); + AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction', 'cashout', 'withdrawal'); CREATE TABLE tan_challenges (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -520,7 +520,7 @@ LANGUAGE plpgsql AS $$ DECLARE account_id BIGINT; BEGIN --- check account exists +-- Check account exists SELECT bank_account_id, is_taler_exchange INTO account_id, out_account_is_exchange FROM bank_accounts @@ -533,7 +533,7 @@ ELSIF out_account_is_exchange THEN RETURN; END IF; --- check enough funds +-- Check enough funds SELECT account_balance_is_sufficient(account_id, in_amount) INTO out_balance_insufficient; IF out_balance_insufficient THEN RETURN; @@ -643,12 +643,14 @@ COMMENT ON FUNCTION abort_taler_withdrawal IS 'Abort a withdrawal operation.'; CREATE FUNCTION confirm_taler_withdrawal( IN in_withdrawal_uuid uuid, IN in_confirmation_date BIGINT, + IN in_is_tan BOOLEAN, OUT out_no_op BOOLEAN, OUT out_balance_insufficient BOOLEAN, OUT out_creditor_not_found BOOLEAN, OUT out_exchange_not_found BOOLEAN, OUT out_not_selected BOOLEAN, - OUT out_aborted BOOLEAN + OUT out_aborted BOOLEAN, + OUT out_tan_required BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE @@ -661,21 +663,25 @@ DECLARE exchange_bank_account_id BIGINT; tx_row_id BIGINT; BEGIN -SELECT -- Really no-star policy and instead DECLARE almost one var per column? +SELECT confirmation_done, aborted, NOT selection_done, reserve_pub, subject, selected_exchange_payto, wallet_bank_account, - (amount).val, (amount).frac + (amount).val, (amount).frac, + (NOT in_is_tan AND tan_channel IS NOT NULL) INTO already_confirmed, out_aborted, out_not_selected, reserve_pub_local, subject_local, selected_exchange_payto_local, wallet_bank_account_local, - amount_local.val, amount_local.frac + amount_local.val, amount_local.frac, + out_tan_required FROM taler_withdrawal_operations + JOIN bank_accounts ON wallet_bank_account=bank_account_id + JOIN customers ON owning_customer_id=customer_id WHERE withdrawal_uuid=in_withdrawal_uuid; IF NOT FOUND THEN out_no_op=TRUE; @@ -695,6 +701,11 @@ IF NOT FOUND THEN RETURN; END IF; +-- Check 2FA +IF out_tan_required THEN + RETURN; +END IF; + SELECT -- not checking for accounts existence, as it was done above. transfer.out_balance_insufficient, out_credit_row_id