libeufin

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

commit eabb15f6c29a669f1e2db21fb9d529250540549f
parent 45daf99497fd453b97d6268b6bf88c883ccfeee5
Author: Antoine A <>
Date:   Mon,  6 Nov 2023 10:49:35 +0000

Cashout with common challenge code logic

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 54+++++++++++++++++++++++++++++++++++-------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 446++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 5+++--
Mbank/src/test/kotlin/CoreBankApiTest.kt | 92++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 103++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbank/src/test/kotlin/JsonTest.kt | 4++--
Mbank/src/test/kotlin/helpers.kt | 13++++++++++++-
Mdatabase-versioning/libeufin-bank-0001.sql | 39+++++++++++++++++++++++++++++++--------
Mdatabase-versioning/libeufin-bank-procedures.sql | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
9 files changed, 629 insertions(+), 325 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -64,6 +64,7 @@ fun Routing.coreBankApi(db: Database, ctx: BankConfig) { } private fun Routing.coreBankTokenApi(db: Database) { + val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) auth(db, TokenScope.refreshable) { post("/accounts/{USERNAME}/token") { val maybeAuthToken = call.getAuthToken() @@ -370,7 +371,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { } } -fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { +private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/withdrawals") { val req = call.receive<BankAccountCreateWithdrawalRequest>() @@ -457,7 +458,11 @@ fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } } -fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { +private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { + val TAN_RETRY_COUNTER: Int = 3; + val TAN_VALIDITY_PERIOD: Duration = Duration.ofHours(1) + val TAN_RETRANSMISSION_PERIOD: Duration = Duration.ofMinutes(1) + auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/cashouts") { val req = call.receive<CashoutRequest>() @@ -465,20 +470,21 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { ctx.checkInternalCurrency(req.amount_debit) ctx.checkFiatCurrency(req.amount_credit) - val opId = UUID.randomUUID() val tanChannel = req.tan_channel ?: TanChannel.sms - val tanCode = UUID.randomUUID().toString() - val (status, info) = db.cashoutCreate( + val res = db.cashout.create( accountUsername = username, - cashoutUuid = opId, + requestUid = req.request_uid, + cashoutUuid = UUID.randomUUID(), amountDebit = req.amount_debit, amountCredit = req.amount_credit, subject = req.subject ?: "", // TODO default subject - creationTime = Instant.now(), tanChannel = tanChannel, - tanCode = tanCode + tanCode = UUID.randomUUID().toString(), + now = Instant.now(), + retryCounter = TAN_RETRY_COUNTER, + validityPeriod = TAN_VALIDITY_PERIOD ) - when (status) { + when (res.status) { CashoutCreationResult.ACCOUNT_NOT_FOUND -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT @@ -499,22 +505,28 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { "Account '$username' missing info for tan channel ${req.tan_channel}", TalerErrorCode.BANK_MISSING_TAN_INFO ) + CashoutCreationResult.REQUEST_UID_REUSE -> throw conflict( + "request_uid used already", + TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED + ) CashoutCreationResult.SUCCESS -> { - when (tanChannel) { - TanChannel.sms -> throw Exception("TODO") - TanChannel.email -> throw Exception("TODO") - TanChannel.file -> { - File("/tmp/cashout-tan.txt").writeText(tanCode) + res.tanCode?.run { + when (tanChannel) { + TanChannel.sms -> throw Exception("TODO") + TanChannel.email -> throw Exception("TODO") + TanChannel.file -> { + File("/tmp/cashout-tan.txt").writeText(this) + } } } - // TODO delete on error or commit transaction on error - call.respond(CashoutPending(opId.toString())) + db.cashout.markSent(res.id!!, Instant.now(), TAN_RETRANSMISSION_PERIOD) + call.respond(CashoutPending(res.id.toString())) } } } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") { val opId = call.uuidUriComponent("CASHOUT_ID") - when (db.cashoutAbort(opId)) { + when (db.cashout.abort(opId)) { AbortResult.NOT_FOUND -> throw notFound( "Cashout operation $opId not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND @@ -529,7 +541,7 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") { val req = call.receive<CashoutConfirm>() val opId = call.uuidUriComponent("CASHOUT_ID") - when (db.cashoutConfirm( + when (db.cashout.confirm( opUuid = opId, tanCode = req.tan, timestamp = Instant.now() @@ -544,7 +556,11 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { ) CashoutConfirmationResult.BAD_TAN_CODE -> throw forbidden( "Incorrect TAN code", - TalerErrorCode.END + TalerErrorCode.END // TODO new ec + ) + CashoutConfirmationResult.NO_RETRY -> throw forbidden( + "Too manny failed confirmation attempt", + TalerErrorCode.END // TODO new ec ) CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( "Insufficient funds", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -25,9 +25,10 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File import java.sql.* -import java.time.Instant +import java.time.* import java.util.* import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlinx.coroutines.flow.* import kotlinx.coroutines.* @@ -91,9 +92,9 @@ private fun PreparedStatement.executeUpdateViolation(): Boolean { } } -class Database(dbConfig: String, private val bankCurrency: String, private val fiatCurrency: String?): java.io.Closeable { +class Database(dbConfig: String, internal val bankCurrency: String, internal val fiatCurrency: String?): java.io.Closeable { val dbPool: HikariDataSource - private val notifWatcher: NotificationWatcher + internal val notifWatcher: NotificationWatcher init { val pgSource = pgDataSource(dbConfig) @@ -105,6 +106,8 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f notifWatcher = NotificationWatcher(pgSource) } + val cashout = CashoutDAO(this) + override fun close() { dbPool.close() } @@ -397,9 +400,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f ), credit_debit_indicator = if (it.getBoolean("has_debt")) { - CorebankCreditDebitInfo.debit + CreditDebitInfo.debit } else { - CorebankCreditDebitInfo.credit + CreditDebitInfo.credit } ), debit_threshold = TalerAmount( @@ -500,9 +503,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f currency = internalCurrency ), credit_debit_indicator = if (it.getBoolean("has_debt")) { - CorebankCreditDebitInfo.debit + CreditDebitInfo.debit } else { - CorebankCreditDebitInfo.credit + CreditDebitInfo.credit } ) ) @@ -541,9 +544,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f currency = bankCurrency ), credit_debit_indicator = if (it.getBoolean("balance_has_debt")) { - CorebankCreditDebitInfo.debit + CreditDebitInfo.debit } else { - CorebankCreditDebitInfo.credit + CreditDebitInfo.credit } ), debit_threshold = TalerAmount( @@ -1080,205 +1083,6 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f } /** - * Creates a cashout operation in the database. - */ - suspend fun cashoutCreate( - accountUsername: String, - cashoutUuid: UUID, - amountDebit: TalerAmount, - amountCredit: TalerAmount, - subject: String, - creationTime: Instant, - tanChannel: TanChannel, - tanCode: String, - ): Pair<CashoutCreationResult, String?> = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - out_bad_conversion, - out_account_not_found, - out_account_is_exchange, - out_missing_tan_info, - out_balance_insufficient, - out_tan_info - FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?); - """) - stmt.setString(1, accountUsername) - stmt.setObject(2, cashoutUuid) - stmt.setLong(3, amountDebit.value) - stmt.setInt(4, amountDebit.frac) - stmt.setLong(5, amountCredit.value) - stmt.setInt(6, amountCredit.frac) - stmt.setString(7, subject) - stmt.setLong(8, creationTime.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.setString(9, tanChannel.name) - stmt.setString(10, tanCode) - stmt.executeQuery().use { - var info: String? = null; - val status = when { - !it.next() -> - throw internalServerError("No result from DB procedure cashout_create") - it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BAD_CONVERSION - it.getBoolean("out_account_not_found") -> CashoutCreationResult.ACCOUNT_NOT_FOUND - it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.ACCOUNT_IS_EXCHANGE - it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MISSING_TAN_INFO - it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BALANCE_INSUFFICIENT - else -> { - info = it.getString("out_tan_info") - CashoutCreationResult.SUCCESS - } - } - Pair(status, info) - } - } - - suspend fun cashoutAbort(opUUID: UUID): AbortResult = conn { conn -> - val stmt = conn.prepareStatement(""" - UPDATE cashout_operations - SET aborted = tan_confirmation_time IS NULL - WHERE cashout_uuid=? - RETURNING tan_confirmation_time IS NOT NULL - """) - stmt.setObject(1, opUUID) - when (stmt.oneOrNull { it.getBoolean(1) }) { - null -> AbortResult.NOT_FOUND - true -> AbortResult.CONFIRMED - false -> AbortResult.SUCCESS - } - } - - suspend fun cashoutConfirm( - opUuid: UUID, - tanCode: String, - timestamp: Instant, - accountServicerReference: String = "NOT-USED", - endToEndId: String = "NOT-USED", - paymentInfId: String = "NOT-USED" - ): CashoutConfirmationResult = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - out_no_op, - out_bad_code, - out_balance_insufficient, - out_aborted - FROM cashout_confirm(?, ?, ?, ?, ?, ?); - """) - stmt.setObject(1, opUuid) - stmt.setString(2, tanCode) - stmt.setLong(3, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.setString(4, accountServicerReference) - stmt.setString(5, endToEndId) - stmt.setString(6, paymentInfId) - stmt.executeQuery().use { - when { - !it.next() -> - throw internalServerError("No result from DB procedure cashout_create") - it.getBoolean("out_no_op") -> CashoutConfirmationResult.OP_NOT_FOUND - it.getBoolean("out_bad_code") -> CashoutConfirmationResult.BAD_TAN_CODE - it.getBoolean("out_balance_insufficient") -> CashoutConfirmationResult.BALANCE_INSUFFICIENT - it.getBoolean("out_aborted") -> CashoutConfirmationResult.ABORTED - else -> CashoutConfirmationResult.SUCCESS - } - } - } - - /** - * This type is used by the cashout /abort handler. - */ - enum class CashoutDeleteResult { - SUCCESS, - CONFLICT_ALREADY_CONFIRMED - } - - /** - * Deletes a cashout operation from the database. - */ - suspend fun cashoutDelete(opUuid: UUID): CashoutDeleteResult = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT out_already_confirmed - FROM cashout_delete(?) - """) - stmt.setObject(1, opUuid) - stmt.executeQuery().use { - when { - !it.next() -> throw internalServerError("Cashout deletion gave no result") - it.getBoolean("out_already_confirmed") -> CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED - else -> CashoutDeleteResult.SUCCESS - } - } - } - - /** - * Gets a cashout operation from the database, according - * to its uuid. - */ - suspend fun cashoutGetFromUuid(opUuid: UUID): Cashout? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - (amount_debit).val as amount_debit_val - ,(amount_debit).frac as amount_debit_frac - ,(amount_credit).val as amount_credit_val - ,(amount_credit).frac as amount_credit_frac - ,buy_at_ratio - ,(buy_in_fee).val as buy_in_fee_val - ,(buy_in_fee).frac as buy_in_fee_frac - ,sell_at_ratio - ,(sell_out_fee).val as sell_out_fee_val - ,(sell_out_fee).frac as sell_out_fee_frac - ,subject - ,creation_time - ,tan_channel - ,tan_code - ,bank_account - ,credit_payto_uri - ,cashout_currency - ,tan_confirmation_time - ,local_transaction - FROM cashout_operations - WHERE cashout_uuid=?; - """) - stmt.setObject(1, opUuid) - stmt.oneOrNull { - Cashout( - amountDebit = TalerAmount( - value = it.getLong("amount_debit_val"), - frac = it.getInt("amount_debit_frac"), - bankCurrency - ), - amountCredit = TalerAmount( - value = it.getLong("amount_credit_val"), - frac = it.getInt("amount_credit_frac"), - bankCurrency - ), - bankAccount = it.getLong("bank_account"), - buyAtRatio = it.getInt("buy_at_ratio"), - buyInFee = TalerAmount( - value = it.getLong("buy_in_fee_val"), - frac = it.getInt("buy_in_fee_frac"), - bankCurrency - ), - credit_payto_uri = it.getString("credit_payto_uri"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutUuid = opUuid, - creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), - sellAtRatio = it.getInt("sell_at_ratio"), - sellOutFee = TalerAmount( - value = it.getLong("sell_out_fee_val"), - frac = it.getInt("sell_out_fee_frac"), - bankCurrency - ), - subject = it.getString("subject"), - tanChannel = TanChannel.valueOf(it.getString("tan_channel")), - tanCode = it.getString("tan_code"), - localTransaction = it.getLong("local_transaction"), - tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) { - 0L -> null - else -> timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank() - } - ) - } - } - - /** * Holds the result of inserting a Taler transfer request * into the database. */ @@ -1645,7 +1449,8 @@ enum class CashoutCreationResult { ACCOUNT_NOT_FOUND, ACCOUNT_IS_EXCHANGE, MISSING_TAN_INFO, - BALANCE_INSUFFICIENT + BALANCE_INSUFFICIENT, + REQUEST_UID_REUSE } /** Result status of cashout operation confirmation */ @@ -1654,6 +1459,7 @@ enum class CashoutConfirmationResult { OP_NOT_FOUND, BAD_TAN_CODE, BALANCE_INSUFFICIENT, + NO_RETRY, ABORTED } @@ -1664,7 +1470,7 @@ enum class AbortResult { CONFIRMED } -private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { +internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { private class CountedSharedFlow(val flow: MutableSharedFlow<Long>, var count: Int) private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() @@ -1754,4 +1560,224 @@ private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { suspend fun listenIncoming(account: Long, lambda: suspend (Flow<Long>) -> Unit) { listen(incomingTxFlows, account, lambda) } +} + +class CashoutDAO(private val db: Database) { + data class CashoutCreation( + val status: CashoutCreationResult, + val id: UUID?, + val tanInfo: String?, + val tanCode: String? + ) + suspend fun create( + accountUsername: String, + requestUid: ShortHashCode, + cashoutUuid: UUID, + amountDebit: TalerAmount, + amountCredit: TalerAmount, + subject: String, + tanChannel: TanChannel, + tanCode: String, + now: Instant, + retryCounter: Int, + validityPeriod: Duration + ): CashoutCreation = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + out_bad_conversion, + out_account_not_found, + out_account_is_exchange, + out_missing_tan_info, + out_balance_insufficient, + out_request_uid_reuse, + out_cashout_uuid, + out_tan_info, + out_tan_code + FROM cashout_create(?, ?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?) + """) + stmt.setString(1, accountUsername) + stmt.setBytes(2, requestUid.raw) + stmt.setObject(3, cashoutUuid) + stmt.setLong(4, amountDebit.value) + stmt.setInt(5, amountDebit.frac) + stmt.setLong(6, amountCredit.value) + stmt.setInt(7, amountCredit.frac) + stmt.setString(8, subject) + stmt.setLong(9, now.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setString(10, tanChannel.name) + stmt.setString(11, tanCode) + stmt.setInt(12, retryCounter) + stmt.setLong(13, TimeUnit.MICROSECONDS.convert(validityPeriod)) + stmt.executeQuery().use { + var id: UUID? = null + var info: String? = null; + var code: String? = null; + val status = when { + !it.next() -> + throw internalServerError("No result from DB procedure cashout_create") + it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BAD_CONVERSION + it.getBoolean("out_account_not_found") -> CashoutCreationResult.ACCOUNT_NOT_FOUND + it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.ACCOUNT_IS_EXCHANGE + it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MISSING_TAN_INFO + it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BALANCE_INSUFFICIENT + it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.REQUEST_UID_REUSE + else -> { + id = it.getObject("out_cashout_uuid") as UUID + info = it.getString("out_tan_info") + code = it.getString("out_tan_code") + CashoutCreationResult.SUCCESS + } + } + CashoutCreation(status, id, info, code) + } + } + + suspend fun markSent( + uuid: UUID, + now: Instant, + retransmissionPeriod: Duration + ) = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT challenge_mark_sent(challenge, ?, ?) + FROM cashout_operations + WHERE cashout_uuid=? + """) + stmt.setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod)) + stmt.setObject(3, uuid) + stmt.executeQueryCheck() + } + + suspend fun abort(opUUID: UUID): AbortResult = db.conn { conn -> + val stmt = conn.prepareStatement(""" + UPDATE cashout_operations + SET aborted = local_transaction IS NULL + WHERE cashout_uuid=? + RETURNING local_transaction IS NOT NULL + """) + stmt.setObject(1, opUUID) + when (stmt.oneOrNull { it.getBoolean(1) }) { + null -> AbortResult.NOT_FOUND + true -> AbortResult.CONFIRMED + false -> AbortResult.SUCCESS + } + } + + suspend fun confirm( + opUuid: UUID, + tanCode: String, + timestamp: Instant + ): CashoutConfirmationResult = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + out_no_op, + out_bad_code, + out_balance_insufficient, + out_aborted, + out_no_retry + FROM cashout_confirm(?, ?, ?); + """) + stmt.setObject(1, opUuid) + stmt.setString(2, tanCode) + stmt.setLong(3, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.executeQuery().use { + when { + !it.next() -> + throw internalServerError("No result from DB procedure cashout_create") + it.getBoolean("out_no_op") -> CashoutConfirmationResult.OP_NOT_FOUND + it.getBoolean("out_bad_code") -> CashoutConfirmationResult.BAD_TAN_CODE + it.getBoolean("out_balance_insufficient") -> CashoutConfirmationResult.BALANCE_INSUFFICIENT + it.getBoolean("out_aborted") -> CashoutConfirmationResult.ABORTED + it.getBoolean("out_no_retry") -> CashoutConfirmationResult.NO_RETRY + else -> CashoutConfirmationResult.SUCCESS + } + } + } + + enum class CashoutDeleteResult { + SUCCESS, + CONFLICT_ALREADY_CONFIRMED + } + + suspend fun delete(opUuid: UUID): CashoutDeleteResult = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT out_already_confirmed + FROM cashout_delete(?) + """) + stmt.setObject(1, opUuid) + stmt.executeQuery().use { + when { + !it.next() -> throw internalServerError("Cashout deletion gave no result") + it.getBoolean("out_already_confirmed") -> CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED + else -> CashoutDeleteResult.SUCCESS + } + } + } + + suspend fun getFromUuid(opUuid: UUID): Cashout? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount_debit).val as amount_debit_val + ,(amount_debit).frac as amount_debit_frac + ,(amount_credit).val as amount_credit_val + ,(amount_credit).frac as amount_credit_frac + ,buy_at_ratio + ,(buy_in_fee).val as buy_in_fee_val + ,(buy_in_fee).frac as buy_in_fee_frac + ,sell_at_ratio + ,(sell_out_fee).val as sell_out_fee_val + ,(sell_out_fee).frac as sell_out_fee_frac + ,subject + ,creation_time + ,tan_channel + ,tan_code + ,bank_account + ,credit_payto_uri + ,cashout_currency + ,tan_confirmation_time + ,local_transaction + FROM cashout_operations + WHERE cashout_uuid=?; + """) + stmt.setObject(1, opUuid) + stmt.oneOrNull { + Cashout( + amountDebit = TalerAmount( + value = it.getLong("amount_debit_val"), + frac = it.getInt("amount_debit_frac"), + db.bankCurrency + ), + amountCredit = TalerAmount( + value = it.getLong("amount_credit_val"), + frac = it.getInt("amount_credit_frac"), + db.bankCurrency + ), + bankAccount = it.getLong("bank_account"), + buyAtRatio = it.getInt("buy_at_ratio"), + buyInFee = TalerAmount( + value = it.getLong("buy_in_fee_val"), + frac = it.getInt("buy_in_fee_frac"), + db.bankCurrency + ), + credit_payto_uri = it.getString("credit_payto_uri"), + cashoutCurrency = it.getString("cashout_currency"), + cashoutUuid = opUuid, + creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), + sellAtRatio = it.getInt("sell_at_ratio"), + sellOutFee = TalerAmount( + value = it.getLong("sell_out_fee_val"), + frac = it.getInt("sell_out_fee_frac"), + db.bankCurrency + ), + subject = it.getString("subject"), + tanChannel = TanChannel.valueOf(it.getString("tan_channel")), + tanCode = it.getString("tan_code"), + localTransaction = it.getLong("local_transaction"), + tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) { + 0L -> null + else -> timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank() + } + ) + } + } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -260,14 +260,14 @@ data class Config( val version: String = "0:0:0" } -enum class CorebankCreditDebitInfo { +enum class CreditDebitInfo { credit, debit } @Serializable data class Balance( val amount: TalerAmount, - val credit_debit_indicator: CorebankCreditDebitInfo, + val credit_debit_indicator: CreditDebitInfo, ) /** @@ -434,6 +434,7 @@ data class BankWithdrawalOperationPostResponse( @Serializable data class CashoutRequest( + val request_uid: ShortHashCode, val subject: String?, val amount_debit: TalerAmount, val amount_credit: TalerAmount, diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -241,21 +241,9 @@ class CoreBankAccountsMgmtApiTest { "username" to "foo$it" } }.assertCreated() - client.get("/accounts/foo$it") { - basicAuth("admin", "admin-password") - }.assertOk().run { - val obj: AccountData = Json.decodeFromString(bodyAsText()) - assertEquals(TalerAmount("KUDOS:100"), obj.balance.amount) - assertEquals(CorebankCreditDebitInfo.credit, obj.balance.credit_debit_indicator) - } - } - client.get("/accounts/admin") { - basicAuth("admin", "admin-password") - }.assertOk().run { - val obj: AccountData = Json.decodeFromString(bodyAsText()) - assertEquals(TalerAmount("KUDOS:10000"), obj.balance.amount) - assertEquals(CorebankCreditDebitInfo.debit, obj.balance.credit_debit_indicator) + assertBalance("foo$it", CreditDebitInfo.credit, "KUDOS:100") } + assertBalance("admin", CreditDebitInfo.debit, "KUDOS:10000") // Check unsufficient fund client.post("/accounts") { @@ -792,7 +780,7 @@ class CoreBankTransactionsApiTest { }.assertOk().run { val obj: AccountData = Json.decodeFromString(bodyAsText()) assertEquals( - if (merchantDebt) CorebankCreditDebitInfo.debit else CorebankCreditDebitInfo.credit, + if (merchantDebt) CreditDebitInfo.debit else CreditDebitInfo.credit, obj.balance.credit_debit_indicator) assertEquals(TalerAmount(merchantAmount), obj.balance.amount) } @@ -801,7 +789,7 @@ class CoreBankTransactionsApiTest { }.assertOk().run { val obj: AccountData = Json.decodeFromString(bodyAsText()) assertEquals( - if (customerDebt) CorebankCreditDebitInfo.debit else CorebankCreditDebitInfo.credit, + if (customerDebt) CreditDebitInfo.debit else CreditDebitInfo.credit, obj.balance.credit_debit_indicator) assertEquals(TalerAmount(customerAmount), obj.balance.amount) } @@ -1041,7 +1029,17 @@ class CoreBankWithdrawalApiTest { class CoreBankCashoutApiTest { - fun tanCode(): String = File("/tmp/cashout-tan.txt").readText() + suspend fun tanCode(): String? { + val file = File("/tmp/cashout-tan.txt"); + if (file.exists()) { + val code = file.readText() + file.delete() + return code; + } else { + return null + } + } + private suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount { // Check conversion @@ -1056,6 +1054,7 @@ class CoreBankCashoutApiTest { fun create() = bankSetup { _ -> // TODO auth routine val req = json { + "request_uid" to randShortHashCode() "amount_debit" to "KUDOS:1" "amount_credit" to convert("KUDOS:1") "tan_channel" to "file" @@ -1065,7 +1064,27 @@ class CoreBankCashoutApiTest { client.post("/accounts/customer/cashouts") { basicAuth("customer", "customer-password") jsonBody(req) - }.assertOk() + }.assertOk().run { + val id = json<CashoutPending>().cashout_id + tanCode() + // Check idempotency + client.post("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + jsonBody(req) + }.assertOk().run { + assertEquals(id, json<CashoutPending>().cashout_id) + assertNull(tanCode()) + } + } + + // Trigger conflict due to reused request_uid + client.post("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + jsonBody(req) { + "amount_debit" to "KUDOS:2" + "amount_credit" to convert("KUDOS:2") + } + }.assertConflict().assertErr(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) // Check exchange account client.post("/accounts/exchange/cashouts") { @@ -1118,6 +1137,7 @@ class CoreBankCashoutApiTest { fun abort() = bankSetup { _ -> // TODO auth routine val req = json { + "request_uid" to randShortHashCode() "amount_debit" to "KUDOS:1" "amount_credit" to convert("KUDOS:1") "tan_channel" to "file" @@ -1143,7 +1163,7 @@ class CoreBankCashoutApiTest { // Check abort confirmed client.post("/accounts/customer/cashouts") { basicAuth("customer", "customer-password") - jsonBody(req) + jsonBody(json(req) { "request_uid" to randShortHashCode() }) }.assertOk().run { val uuid = json<CashoutPending>().cashout_id @@ -1176,6 +1196,7 @@ class CoreBankCashoutApiTest { fun confirm() = bankSetup { _ -> // TODO auth routine val req = json { + "request_uid" to randShortHashCode() "amount_debit" to "KUDOS:1" "amount_credit" to convert("KUDOS:1") "tan_channel" to "file" @@ -1185,7 +1206,7 @@ class CoreBankCashoutApiTest { // Check confirm client.post("/accounts/customer/cashouts") { basicAuth("customer", "customer-password") - jsonBody(req) + jsonBody(req) { "request_uid" to randShortHashCode() } }.assertOk().run { val uuid = json<CashoutPending>().cashout_id @@ -1195,46 +1216,31 @@ class CoreBankCashoutApiTest { jsonBody { "tan" to "nice-try" } }.assertForbidden() + val code = tanCode() + // Check OK client.post("/accounts/customer/cashouts/$uuid/confirm") { basicAuth("customer", "customer-password") - jsonBody { "tan" to tanCode() } + jsonBody { "tan" to code } }.assertNoContent() // Check idempotence client.post("/accounts/customer/cashouts/$uuid/confirm") { basicAuth("customer", "customer-password") - jsonBody { "tan" to tanCode() } - }.assertNoContent() - } - - // Check confirm aborted TODO - client.post("/accounts/customer/cashouts") { - basicAuth("customer", "customer-password") - jsonBody(req) - }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id - client.post("/accounts/customer/cashouts/$uuid/abort") { - basicAuth("customer", "customer-password") + jsonBody { "tan" to code } }.assertNoContent() - - // Check error - client.post("/accounts/customer/cashouts/$uuid/confirm") { - basicAuth("customer", "customer-password") - jsonBody { "tan" to tanCode() } - }.assertConflict().assertErr(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) } // Check balance insufficient client.post("/accounts/customer/cashouts") { basicAuth("customer", "customer-password") - jsonBody(req) + jsonBody(json(req) { "request_uid" to randShortHashCode() }) }.assertOk().run { val uuid = json<CashoutPending>().cashout_id // Send too much money client.post("/accounts/customer/transactions") { basicAuth("customer", "customer-password") jsonBody { - "payto_uri" to "payto://iban/merchant-IBAN-XYZ?message=payout&amount=KUDOS:8" + "payto_uri" to "payto://iban/merchant-IBAN-XYZ?message=payout&amount=KUDOS:9" } }.assertNoContent() @@ -1252,13 +1258,13 @@ class CoreBankCashoutApiTest { // Check bad UUID client.post("/accounts/customer/cashouts/chocolate/confirm") { basicAuth("customer", "customer-password") - jsonBody { "tan" to tanCode() } + jsonBody { "tan" to "code" } }.assertBadRequest() // Check unknown client.post("/accounts/customer/cashouts/${UUID.randomUUID()}/confirm") { basicAuth("customer", "customer-password") - jsonBody { "tan" to tanCode() } + jsonBody { "tan" to "code" } }.assertNotFound().assertErr(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -20,11 +20,14 @@ import org.junit.Test import org.postgresql.jdbc.PgConnection import tech.libeufin.bank.* -import tech.libeufin.util.CryptoUtil +import tech.libeufin.util.* import java.sql.DriverManager import java.time.Instant +import java.time.Duration +import java.time.temporal.ChronoUnit import java.util.Random import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.experimental.inv import kotlin.test.* @@ -45,6 +48,104 @@ class DatabaseTest { // Checking idempotency assert(maybeCreateAdminAccount(db, ctx)) } + + @Test + fun challenge() = setup { db, _ -> db.conn { conn -> + val createStmt = conn.prepareStatement("SELECT challenge_create(?,?,?,?)") + val sendStmt = conn.prepareStatement("SELECT challenge_mark_sent(?,?,?)") + val tryStmt = conn.prepareStatement("SELECT ok, no_retry FROM challenge_try(?,?,?)") + val resendStmt = conn.prepareStatement("SELECT challenge_resend(?,?,?,?,?)") + + val validityPeriod = Duration.ofHours(1) + val retransmissionPeriod: Duration = Duration.ofMinutes(1) + val retryCounter = 3 + + fun create(code: String, now: Instant): Long { + createStmt.setString(1, code) + createStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now)) + createStmt.setLong(3, TimeUnit.MICROSECONDS.convert(validityPeriod)) + createStmt.setInt(4, retryCounter) + return createStmt.oneOrNull { it.getLong(1) }!! + } + + fun send(id: Long, now: Instant) { + sendStmt.setLong(1, id) + sendStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now)) + sendStmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod)) + return sendStmt.oneOrNull { }!! + } + + fun cTry(id: Long, code: String, now: Instant): Pair<Boolean, Boolean> { + tryStmt.setLong(1, id) + tryStmt.setString(2, code) + tryStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now)) + return tryStmt.oneOrNull { + Pair(it.getBoolean(1), it.getBoolean(2)) + }!! + } + + fun resend(id: Long, code: String, now: Instant): String? { + resendStmt.setLong(1, id) + resendStmt.setString(2, code) + resendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now)) + resendStmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod)) + resendStmt.setInt(5, retryCounter) + return resendStmt.oneOrNull { it.getString(1) } + } + + val now = Instant.now() + val expired = now + validityPeriod + val retransmit = now + retransmissionPeriod + + // Check basic + create("good-code", now).run { + // Bad code + assertEquals(Pair(false, false), cTry(this, "bad-code", now)) + // Good code + assertEquals(Pair(true, false), cTry(this, "good-code", now)) + // Never resend a confirmed challenge + assertNull(resend(this, "new-code", expired)) + // Confirmed challenge always ok + assertEquals(Pair(true, false), cTry(this, "good-code", now)) + } + + // Check retry + create("good-code", now).run { + send(this, now) + // Bad code + repeat(retryCounter) { + assertEquals(Pair(false, false), cTry(this, "bad-code", now)) + } + // Good code fail + assertEquals(Pair(false, true), cTry(this, "good-code", now)) + // New code + assertEquals("new-code", resend(this, "new-code", now)) + // Good code + assertEquals(Pair(true, false), cTry(this, "new-code", now)) + } + + // Check retransmission and expiration + create("good-code", now).run { + // Failed to send retransmit + assertEquals("good-code", resend(this, "new-code", now)) + // Code successfully sent and still valid + send(this, now) + assertNull(resend(this, "new-code", now)) + // Code is still valid but shoud be resent + assertEquals("good-code", resend(this, "new-code", retransmit)) + // Good code fail because expired + assertEquals(Pair(false, false), cTry(this, "good-code", expired)) + // New code because expired + assertEquals("new-code", resend(this, "new-code", expired)) + // Code successfully sent and still valid + send(this, expired) + assertNull(resend(this, "another-code", expired)) + // Old code no longer workds + assertEquals(Pair(false, false), cTry(this, "good-code", expired)) + // New code works + assertEquals(Pair(true, false), cTry(this, "new-code", expired)) + } + }} } diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -49,8 +49,8 @@ class JsonTest { @Test fun enumSerializer() { - assert("\"credit\"" == Json.encodeToString(CorebankCreditDebitInfo.credit)) - assert("\"debit\"" == Json.encodeToString(CorebankCreditDebitInfo.debit)) + assert("\"credit\"" == Json.encodeToString(CreditDebitInfo.credit)) + assert("\"debit\"" == Json.encodeToString(CreditDebitInfo.debit)) } // Testing JSON <--> TalerAmount diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -92,6 +92,17 @@ suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: TalerAmo }.assertNoContent() } +suspend fun ApplicationTestBuilder.assertBalance(account: String, info: CreditDebitInfo, amount: String) { + client.get("/accounts/$account") { + basicAuth("admin", "admin-password") + }.assertOk().run { + val balance = json<AccountData>().balance; + assertEquals(info, balance.credit_debit_indicator) + assertEquals(TalerAmount(amount), balance.amount) + } +} + + /* ----- Assert ----- */ fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse { @@ -153,7 +164,7 @@ inline fun <reified B> HttpRequestBuilder.jsonBody(b: B, deflate: Boolean = fals } } -inline suspend fun HttpRequestBuilder.jsonBody( +inline fun HttpRequestBuilder.jsonBody( from: JsonObject = JsonObject(emptyMap()), deflate: Boolean = false, builderAction: JsonBuilder.() -> Unit diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -155,10 +155,37 @@ COMMENT ON COLUMN bank_account_transactions.bank_account_id -- end of: money transactions +-- start of: TAN challenge +CREATE TABLE IF NOT EXISTS challenges + (challenge_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE, + code TEXT NOT NULL, + creation_date INT8 NOT NULL, + expiration_date INT8 NOT NULL, + retransmission_date INT8 NOT NULL DEFAULT 0, + retry_counter INT4 NOT NULL, + confirmation_date INT8 DEFAULT NULL); +COMMENT ON TABLE challenges + IS 'Stores a code which is checked for the authentication by SMS, E-Mail..'; +COMMENT ON COLUMN challenges.code + IS 'The pin code which is sent to the user and verified'; +COMMENT ON COLUMN challenges.creation_date + IS 'Creation date of the code'; +COMMENT ON COLUMN challenges.retransmission_date + IS 'When did we last transmit the challenge to the user'; +COMMENT ON COLUMN challenges.expiration_date + IS 'When will the code expire'; +COMMENT ON COLUMN challenges.retry_counter + IS 'How many tries are left for this code must be > 0'; +COMMENT ON COLUMN challenges.confirmation_date + IS 'When was this challenge successfully verified, NULL if pending'; + +-- end of: TAN challenge + -- start of: cashout management CREATE TABLE IF NOT EXISTS cashout_operations (cashout_uuid uuid NOT NULL PRIMARY KEY + ,request_uid BYTEA NOT NULL UNIQUE CHECK (LENGTH(request_uid)=32) ,amount_debit taler_amount NOT NULL ,amount_credit taler_amount NOT NULL ,subject TEXT NOT NULL @@ -167,9 +194,10 @@ CREATE TABLE IF NOT EXISTS cashout_operations REFERENCES bank_accounts(bank_account_id) ON DELETE CASCADE ON UPDATE RESTRICT - ,tan_channel tan_enum NOT NULL - ,tan_code TEXT NOT NULL - ,tan_confirmation_time BIGINT DEFAULT NULL + ,challenge BIGINT NOT NULL UNIQUE + REFERENCES challenges(challenge_id) + ON DELETE CASCADE + ON UPDATE RESTRICT ,aborted BOOLEAN NOT NULL DEFAULT FALSE ,local_transaction BIGINT UNIQUE DEFAULT NULL-- FIXME: Comment that the transaction only gets created after the TAN confirmation REFERENCES bank_account_transactions(bank_transaction_id) @@ -179,11 +207,6 @@ CREATE TABLE IF NOT EXISTS cashout_operations -- FIXME: table comment missing -COMMENT ON COLUMN cashout_operations.tan_confirmation_time - IS 'Timestamp when the customer confirmed the cash-out operation via TAN'; -COMMENT ON COLUMN cashout_operations.tan_code - IS 'text that the customer must send to confirm the cash-out operation'; - -- end of: cashout management -- start of: Taler integration diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -991,25 +991,32 @@ END $$; CREATE OR REPLACE FUNCTION cashout_create( IN in_account_username TEXT, + IN in_request_uid BYTEA, IN in_cashout_uuid uuid, IN in_amount_debit taler_amount, IN in_amount_credit taler_amount, IN in_subject TEXT, - IN in_creation_time BIGINT, + IN in_now_date INT8, IN in_tan_channel tan_enum, IN in_tan_code TEXT, + IN in_retry_counter INT4, + IN in_validity_period INT8, -- Error status OUT out_bad_conversion BOOLEAN, OUT out_account_not_found BOOLEAN, OUT out_account_is_exchange BOOLEAN, OUT out_missing_tan_info BOOLEAN, OUT out_balance_insufficient BOOLEAN, + OUT out_request_uid_reuse BOOLEAN, -- Success return - OUT out_tan_info TEXT + OUT out_cashout_uuid uuid, + OUT out_tan_info TEXT, + OUT out_tan_code TEXT ) LANGUAGE plpgsql AS $$ DECLARE account_id BIGINT; +challenge_id BIGINT; BEGIN -- check conversion SELECT too_small OR in_amount_credit!=to_amount INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'sell'::text); @@ -1045,39 +1052,57 @@ IF out_balance_insufficient THEN RETURN; END IF; --- Create cashout operation -INSERT INTO cashout_operations ( - cashout_uuid - ,amount_debit - ,amount_credit - ,subject - ,creation_time - ,bank_account - ,tan_channel - ,tan_code -) VALUES ( - in_cashout_uuid - ,in_amount_debit - ,in_amount_credit - ,in_subject - ,in_creation_time - ,account_id - ,in_tan_channel - ,in_tan_code -); +-- Check for idempotence and conflict +SELECT (amount_debit != in_amount_debit + OR subject != in_subject + OR bank_account != account_id) + ,challenge, cashout_uuid + INTO out_request_uid_reuse, challenge_id, out_cashout_uuid + FROM cashout_operations + WHERE request_uid = in_request_uid; + +IF NOT found THEN + -- New cashout + out_cashout_uuid = in_cashout_uuid; + out_tan_code = in_tan_code; + + -- Create challenge + SELECT challenge_create(in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO challenge_id; + + -- Create cashout operation + INSERT INTO cashout_operations ( + cashout_uuid + ,request_uid + ,amount_debit + ,amount_credit + ,subject + ,creation_time + ,bank_account + ,challenge + ) VALUES ( + in_cashout_uuid + ,in_request_uid + ,in_amount_debit + ,in_amount_credit + ,in_subject + ,in_now_date + ,account_id + ,challenge_id + ); +ELSE -- Already exist, check challenge retransmission + SELECT challenge_resend(challenge_id, in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO out_tan_code; +END IF; END $$; CREATE OR REPLACE FUNCTION cashout_confirm( IN in_cashout_uuid uuid, IN in_tan_code TEXT, - IN in_confirmation_time BIGINT, - IN in_acct_svcr_ref TEXT, - IN in_pmt_inf_id TEXT, - IN in_end_to_end_id TEXT, + IN in_now_date BIGINT, OUT out_no_op BOOLEAN, OUT out_bad_code BOOLEAN, OUT out_balance_insufficient BOOLEAN, - OUT out_aborted BOOLEAN + OUT out_aborted BOOLEAN, + OUT out_no_retry BOOLEAN ) LANGUAGE plpgsql as $$ DECLARE @@ -1086,26 +1111,34 @@ DECLARE already_confirmed BOOLEAN; subject_local TEXT; amount_local taler_amount; + challenge_id BIGINT; + tx_id BIGINT; BEGIN -- Retrieve cashout operation info SELECT - tan_code != in_tan_code, - tan_confirmation_time IS NOT NULL, + local_transaction IS NOT NULL, aborted, subject, - bank_account, + bank_account, challenge, (amount_debit).val, (amount_debit).frac INTO - out_bad_code, already_confirmed, out_aborted, subject_local, - wallet_account_id, + wallet_account_id, challenge_id, amount_local.val, amount_local.frac FROM cashout_operations WHERE cashout_uuid=in_cashout_uuid; IF NOT FOUND THEN out_no_op=TRUE; RETURN; -ELSIF out_bad_code OR out_bad_code OR out_aborted THEN +ELSIF already_confirmed OR out_aborted THEN + RETURN; +END IF; + +-- Check challenge +SELECT NOT ok, no_retry + INTO out_bad_code, out_no_retry + FROM challenge_try(challenge_id, in_tan_code, in_now_date); +IF out_bad_code OR out_no_retry THEN RETURN; END IF; @@ -1118,16 +1151,17 @@ SELECT bank_account_id WHERE login = 'admin'; -- Perform bank wire transfer -SELECT transfer.out_balance_insufficient INTO out_balance_insufficient +SELECT transfer.out_balance_insufficient, out_debit_row_id +INTO out_balance_insufficient, tx_id FROM bank_wire_transfer( admin_account_id, wallet_account_id, subject_local, amount_local, - in_confirmation_time, - in_acct_svcr_ref, - in_pmt_inf_id, - in_end_to_end_id + in_now_date, + 'not-used', + 'not-used', + 'not-used' ) as transfer; IF out_balance_insufficient THEN RETURN; @@ -1135,10 +1169,96 @@ END IF; -- Confirm operation UPDATE cashout_operations - SET tan_confirmation_time = in_confirmation_time + SET local_transaction = tx_id WHERE cashout_uuid=in_cashout_uuid; END $$; +CREATE OR REPLACE FUNCTION challenge_create ( + IN in_code TEXT, + IN in_now_date INT8, + IN in_validity_period INT8, + IN in_retry_counter INT4, + OUT out_challenge_id BIGINT +) +LANGUAGE sql AS $$ + INSERT INTO challenges ( + code, + creation_date, + expiration_date, + retry_counter + ) VALUES ( + in_code, + in_now_date, + in_now_date + in_validity_period, + in_retry_counter + ) RETURNING challenge_id +$$; +COMMENT ON FUNCTION challenge_create IS 'Create a new challenge, return the generated id'; + +CREATE OR REPLACE FUNCTION challenge_mark_sent ( + IN in_challenge_id BIGINT, + IN in_now_date INT8, + IN in_retransmission_period INT8 +) RETURNS void +LANGUAGE sql AS $$ + UPDATE challenges SET + retransmission_date = in_now_date + in_retransmission_period + WHERE challenge_id = in_challenge_id; +$$; +COMMENT ON FUNCTION challenge_create IS 'Register a challenge as successfully sent'; + +CREATE OR REPLACE FUNCTION challenge_resend ( + IN in_challenge_id BIGINT, + IN in_code TEXT, -- New code to use if the old code expired + IN in_now_date INT8, + IN in_validity_period INT8, + IN in_retry_counter INT4, + OUT out_tan_code TEXT -- Code to send, NULL if nothing should be sent +) +LANGUAGE plpgsql as $$ +DECLARE +expired BOOLEAN; +retransmit BOOLEAN; +BEGIN +-- Recover expiration date +SELECT + (in_now_date >= expiration_date OR retry_counter <= 0) AND confirmation_date IS NULL + ,in_now_date >= retransmission_date AND confirmation_date IS NULL + ,code +INTO expired, retransmit, out_tan_code +FROM challenges WHERE challenge_id = in_challenge_id; + +IF expired THEN + UPDATE challenges SET + code = in_code + ,expiration_date = in_now_date + in_validity_period + ,retry_counter = in_retry_counter + WHERE challenge_id = in_challenge_id; + out_tan_code = in_code; +ELSIF NOT retransmit THEN + out_tan_code = NULL; +END IF; +END $$; +COMMENT ON FUNCTION challenge_resend IS 'Get the challenge code to send, return NULL if nothing should be sent'; + +CREATE OR REPLACE FUNCTION challenge_try ( + IN in_challenge_id BIGINT, + IN in_code TEXT, + IN in_now_date INT8, + OUT ok BOOLEAN, + OUT no_retry BOOLEAN +) +LANGUAGE sql as $$ + UPDATE challenges SET + confirmation_date = CASE + WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = in_code) THEN in_now_date + ELSE confirmation_date + END, + retry_counter = retry_counter - 1 + WHERE challenge_id = in_challenge_id + RETURNING confirmation_date IS NOT NULL, retry_counter < 0 AND confirmation_date IS NULL; +$$; +COMMENT ON FUNCTION challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed'; CREATE OR REPLACE FUNCTION stats_get_frame( IN now TIMESTAMP,