libeufin

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

commit 7f1d0c909e29c0d0a0880dffce7a572f13726b9c
parent 2e848710da3032ea739cd91a4c78b2936e5a031c
Author: Antoine A <>
Date:   Wed,  3 Jan 2024 00:32:08 +0000

2fa for bank transactions

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 75+++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 7++++++-
Mbank/src/test/kotlin/AmountTest.kt | 1+
Mbank/src/test/kotlin/CoreBankApiTest.kt | 25++++++++++++++++++++-----
Mbank/src/test/kotlin/helpers.kt | 4++--
Mdatabase-versioning/libeufin-bank-0002.sql | 2+-
Mdatabase-versioning/libeufin-bank-procedures.sql | 9++++++---
9 files changed, 82 insertions(+), 46 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -374,6 +374,43 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } } +suspend fun ApplicationCall.bankTransactionHttp(db: Database, ctx: BankConfig, req: TransactionCreateRequest, is2fa: Boolean) { + 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 = is2fa + ) + when (res) { + BankTransactionResult.UnknownDebtor -> throw unknownAccount(username) + BankTransactionResult.TanRequired -> { + 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 conflict( + "Creditor account was not found", + TalerErrorCode.BANK_UNKNOWN_CREDITOR + ) + 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 -> respond(TransactionCreateResponse(res.id)) + } +} + private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.readonly) { get("/accounts/{USERNAME}/transactions") { @@ -399,38 +436,8 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { } auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/transactions") { - val tx = call.receive<TransactionCreateRequest>() - val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") - val amount = - tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount") - ctx.checkRegionalCurrency(amount) - val res = db.transaction.create( - creditAccountPayto = tx.payto_uri, - debitAccountUsername = username, - subject = subject, - amount = amount, - timestamp = Instant.now(), - ) - when (res) { - is BankTransactionResult.UnknownDebtor -> throw unknownAccount(username) - is BankTransactionResult.BothPartySame -> throw conflict( - "Wire transfer attempted with credit and debit party being the same bank account", - TalerErrorCode.BANK_SAME_ACCOUNT - ) - is BankTransactionResult.UnknownCreditor -> throw conflict( - "Creditor account was not found", - TalerErrorCode.BANK_UNKNOWN_CREDITOR - ) - is BankTransactionResult.AdminCreditor -> throw conflict( - "Cannot transfer money to admin account", - TalerErrorCode.BANK_ADMIN_CREDITOR - ) - is BankTransactionResult.BalanceInsufficient -> throw conflict( - "Insufficient funds", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id)) - } + val req = call.receive<TransactionCreateRequest>() + call.bankTransactionHttp(db, ctx, req, false) } } } @@ -800,6 +807,10 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { Operation.account_delete -> { call.deleteAccountHttp(db, ctx, true) } + Operation.bank_transaction -> { + val req = Json.decodeFromString<TransactionCreateRequest>(res.body) + call.bankTransactionHttp(db, ctx, req, true) + } } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -74,7 +74,8 @@ enum class Timeframe { enum class Operation { account_reconfig, - account_delete + account_delete, + bank_transaction } @Serializable(with = Option.Serializer::class) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -144,7 +144,7 @@ class AccountDAO(private val db: Database) { if (bonus.value != 0L || bonus.frac != 0) { conn.prepareStatement(""" SELECT out_balance_insufficient - FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?) + FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true) """).run { setString(1, internalPaytoUri.canonical) setLong(2, bonus.value) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -37,6 +37,7 @@ class TransactionDAO(private val db: Database) { object UnknownDebtor: BankTransactionResult() object BothPartySame: BankTransactionResult() object BalanceInsufficient: BankTransactionResult() + object TanRequired: BankTransactionResult() } /** Create a new transaction */ @@ -46,6 +47,7 @@ class TransactionDAO(private val db: Database) { subject: String, amount: TalerAmount, timestamp: Instant, + is2fa: Boolean ): BankTransactionResult = db.serializable { conn -> val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank(); conn.transaction { @@ -55,6 +57,7 @@ class TransactionDAO(private val db: Database) { ,out_debtor_not_found ,out_same_account ,out_balance_insufficient + ,out_tan_required ,out_credit_bank_account_id ,out_debit_bank_account_id ,out_credit_row_id @@ -62,7 +65,7 @@ class TransactionDAO(private val db: Database) { ,out_creditor_is_exchange ,out_debtor_is_exchange ,out_creditor_admin - FROM bank_transaction(?,?,?,(?,?)::taler_amount,?) + FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?) """ ) stmt.setString(1, creditAccountPayto.canonical) @@ -71,6 +74,7 @@ class TransactionDAO(private val db: Database) { stmt.setLong(4, amount.value) stmt.setInt(5, amount.frac) stmt.setLong(6, now) + stmt.setBoolean(7, is2fa) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("Bank transaction didn't properly return") @@ -79,6 +83,7 @@ class TransactionDAO(private val db: Database) { it.getBoolean("out_same_account") -> BankTransactionResult.BothPartySame it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BalanceInsufficient it.getBoolean("out_creditor_admin") -> BankTransactionResult.AdminCreditor + it.getBoolean("out_tan_required") -> BankTransactionResult.TanRequired else -> { val creditAccountId = it.getLong("out_credit_bank_account_id") val creditRowId = it.getLong("out_credit_row_id") diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -55,6 +55,7 @@ class AmountTest { subject = "test", amount = due, timestamp = Instant.now(), + is2fa = false ) val txBool = when (txRes) { BankTransactionResult.BalanceInsufficient -> false diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -792,8 +792,7 @@ class CoreBankTransactionsApiTest { repeat(2) { tx("merchant", "KUDOS:3", "customer") } - client.post("/accounts/merchant/transactions") { - pwAuth("merchant") + client.postA("/accounts/merchant/transactions") { json { "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5" } @@ -811,9 +810,9 @@ class CoreBankTransactionsApiTest { assertBalance("exchange", "+KUDOS:0") tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction - val reserve_pub = randEddsaPublicKey(); - tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reserve_pub)) // Accept incoming - tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reserve_pub)) // Bounce reserve_pub reuse + val reservePub = randEddsaPublicKey(); + tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Accept incoming + tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Bounce reserve_pub reuse assertBalance("merchant", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") @@ -828,6 +827,22 @@ class CoreBankTransactionsApiTest { tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Warn wtid reuse assertBalance("merchant", "+KUDOS:3") assertBalance("exchange", "-KUDOS:3") + + // Check 2fa + fillTanInfo("merchant") + assertBalance("merchant", "+KUDOS:3") + assertBalance("customer", "+KUDOS:0") + client.postA("/accounts/merchant/transactions") { + json { + "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1" + } + }.assertChallenge { _,_-> + assertBalance("merchant", "+KUDOS:3") + assertBalance("customer", "+KUDOS:0") + }.assertOkJson <TransactionCreateResponse> { + assertBalance("merchant", "+KUDOS:2") + assertBalance("customer", "+KUDOS:1") + } } } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -187,7 +187,7 @@ suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, json { "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLQueryComponent()}&amount=$amount" } - }.assertOkJson<TransactionCreateResponse>().row_id + }.maybeChallenge().assertOkJson<TransactionCreateResponse>().row_id } /** Perform a taler outgoing transaction of [amount] from exchange to merchant */ @@ -338,7 +338,7 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse { return this } -suspend fun HttpResponse.challenge(): HttpResponse { +suspend fun HttpResponse.maybeChallenge(): HttpResponse { return if (this.status == HttpStatusCode.Accepted) { this.assertChallenge() } else { diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql @@ -25,7 +25,7 @@ ALTER TABLE customers ADD tan_channel tan_enum NULL; CREATE TYPE op_enum - AS ENUM ('account_reconfig', 'account_delete'); + AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction'); 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 @@ -442,12 +442,14 @@ CREATE FUNCTION bank_transaction( IN in_subject TEXT, IN in_amount taler_amount, IN in_timestamp BIGINT, + IN in_is_tan BOOLEAN, -- Error status OUT out_creditor_not_found BOOLEAN, OUT out_debtor_not_found BOOLEAN, OUT out_same_account BOOLEAN, OUT out_balance_insufficient BOOLEAN, OUT out_creditor_admin BOOLEAN, + OUT out_tan_required BOOLEAN, -- Success return OUT out_credit_bank_account_id BIGINT, OUT out_debit_bank_account_id BIGINT, @@ -471,17 +473,18 @@ ELSIF out_creditor_admin THEN RETURN; END IF; -- Find debit bank account id and check it's a different account -SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id - INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account +SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id, NOT in_is_tan AND tan_channel IS NOT NULL + INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account, out_tan_required FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE login = in_debit_account_username; IF NOT FOUND THEN out_debtor_not_found=TRUE; RETURN; -ELSIF out_same_account THEN +ELSIF out_same_account OR out_tan_required THEN RETURN; END IF; +-- TODO check balance insufficient ? -- Perform bank transfer SELECT transfer.out_balance_insufficient,