commit a8300c3edc9d9d19097e752e43f954a84428febb
parent 95a2a1dfabbb5c6f86adb5fb5244886370252c9a
Author: Antoine A <>
Date: Wed, 3 Jan 2024 02:29:48 +0000
2fa for withdrawal
Diffstat:
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