libeufin

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

commit 1b8a37e7e2286c0908a6b2953b539cc4219d3b3b
parent 5f53f42899821e4fb7e353ae5ec6b76981ee5dd9
Author: Antoine A <>
Date:   Tue, 19 Nov 2024 13:22:04 +0100

bank: rate limit active TAN challenges

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 9+++++----
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 10+++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 9++++++++-
Mbank/src/test/kotlin/CoreBankApiTest.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/DatabaseTest.kt | 2+-
Mcommon/src/main/kotlin/ApiError.kt | 4++++
Mcommon/src/main/kotlin/client.kt | 2++
Mdatabase-versioning/libeufin-bank-procedures.sql | 19++++++++++++++++---
8 files changed, 90 insertions(+), 12 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -26,18 +26,19 @@ val BANK_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-bank", "libeufin-ban // TAN const val TAN_RETRY_COUNTER: Int = 3 -val TAN_VALIDITY_PERIOD: Duration = Duration.ofHours(1) -val TAN_RETRANSMISSION_PERIOD: Duration = Duration.ofMinutes(1) +val TAN_VALIDITY_PERIOD: Duration = Duration.ofMinutes(30) +val TAN_RETRANSMISSION_PERIOD: Duration = Duration.ofMinutes(3) // Token -val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) +val TOKEN_DEFAULT_DURATION: Duration = Duration.ofHours(3) // Account val RESERVED_ACCOUNTS = setOf("admin", "bank") const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5 const val MAX_TOKEN_CREATION_ATTEMPTS: Int = 5 +const val MAX_ACTIVE_CHALLENGES: Int = 5 // API version -const val COREBANK_API_VERSION: String = "6:0:3" +const val COREBANK_API_VERSION: String = "7:0:4" const val CONVERSION_API_VERSION: String = "0:1:0" const val INTEGRATION_API_VERSION: String = "4:0:4" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -730,7 +730,8 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { timestamp = Instant.now(), retryCounter = TAN_RETRY_COUNTER, validityPeriod = TAN_VALIDITY_PERIOD, - isAuth = call.isAuthenticated + isAuth = call.isAuthenticated, + maxActive = MAX_ACTIVE_CHALLENGES ) when (res) { TanSendResult.NotFound -> throw notFound( @@ -738,6 +739,10 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) TanSendResult.AuthRequired -> throw missingAuth() + TanSendResult.TooMany -> throw tooManyRequests( + "Too many active challenges", + TalerErrorCode.BANK_TAN_RATE_LIMITED + ) is TanSendResult.Success -> { res.tanCode?.run { val (tanScript, tanEnv) = cfg.tanChannels[res.tanChannel] @@ -810,8 +815,7 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { "Incorrect TAN code", TalerErrorCode.BANK_TAN_CHALLENGE_FAILED ) - TanSolveResult.NoRetry -> throw apiError( - HttpStatusCode.TooManyRequests, + TanSolveResult.NoRetry -> throw tooManyRequests( "Too many failed confirmation attempt", TalerErrorCode.BANK_TAN_RATE_LIMITED ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -63,6 +63,7 @@ class TanDAO(private val db: Database) { data class Success(val tanInfo: String, val tanChannel: TanChannel, val tanCode: String?): TanSendResult data object NotFound: TanSendResult data object AuthRequired: TanSendResult + data object TooMany: TanSendResult } /** Request TAN challenge transmission */ @@ -74,8 +75,12 @@ class TanDAO(private val db: Database) { retryCounter: Int, validityPeriod: Duration, isAuth: Boolean, + maxActive: Int ) = db.serializable( - "SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info, out_auth_required FROM tan_challenge_send(?,?,?,?,?,?,?)" + """ + SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info, out_auth_required, out_too_many + FROM tan_challenge_send(?,?,?,?,?,?,?,?) + """ ) { setLong(1, id) setString(2, username) @@ -84,10 +89,12 @@ class TanDAO(private val db: Database) { setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod)) setInt(6, retryCounter) setBoolean(7, isAuth) + setInt(8, maxActive) one { when { it.getBoolean("out_no_op") -> TanSendResult.NotFound it.getBoolean("out_auth_required") -> TanSendResult.AuthRequired + it.getBoolean("out_too_many") -> TanSendResult.TooMany else -> TanSendResult.Success( tanInfo = it.getString("out_tan_info"), tanChannel = it.getEnum("out_tan_channel"), diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -2092,6 +2092,53 @@ class CoreBankTanApiTest { .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } + @Test + fun sendRateLimited() = bankSetup { + fillTanInfo("merchant") + + suspend fun ApplicationTestBuilder.txChallenge() + = client.postA("/accounts/merchant/transactions") { + json { + "payto_uri" to "$customerPayto?message=tx&amount=KUDOS:0.1" + } + }.assertAcceptedJson<TanChallenge>() + suspend fun ApplicationTestBuilder.submit(challenge: TanChallenge) + = client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") + .assertOkJson<TanTransmission>() + + + // Start a legitimate challenge and submit it + val oldChallenge = txChallenge() + val tanCode = tanCode(submit(oldChallenge).tan_info) + + // Challenge creation is not rate limited + repeat(MAX_ACTIVE_CHALLENGES*2) { + txChallenge() + } + + // Challenge submission is rate limited + repeat(MAX_ACTIVE_CHALLENGES-1) { + submit(txChallenge()) + } + val challenge = txChallenge() + client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") + .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED) + + // Old already submitted challenge still works + val transmission = submit(oldChallenge) + client.postA("/accounts/merchant/challenge/${oldChallenge.challenge_id}/confirm") { + json { "tan" to tanCode } + }.assertNoContent() + + // Now an active challenge slot have been freed + submit(challenge) + + // We are rate limited again + val newChallenge = txChallenge() + client.postA("/accounts/merchant/challenge/${newChallenge.challenge_id}") + .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED) + } + // POST /accounts/{USERNAME}/challenge/{challenge_id} @Test fun sendTanErr() = bankSetup("test_tan_err.conf") { diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -140,7 +140,7 @@ class DatabaseTest { val createStmt = conn.prepareStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)") val markSentStmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)") val tryStmt = conn.prepareStatement("SELECT out_ok, out_no_retry, out_expired FROM tan_challenge_try(?,'customer',?,?,true)") - val sendStmt = conn.prepareStatement("SELECT out_tan_code FROM tan_challenge_send(?,'customer',?,?,?,?,true)") + val sendStmt = conn.prepareStatement("SELECT out_tan_code FROM tan_challenge_send(?,'customer',?,?,?,?,true,10)") val validityPeriod = Duration.ofHours(1) val retransmissionPeriod: Duration = Duration.ofMinutes(1) diff --git a/common/src/main/kotlin/ApiError.kt b/common/src/main/kotlin/ApiError.kt @@ -117,6 +117,10 @@ fun conflict( hint: String, error: TalerErrorCode ): ApiException = apiError(HttpStatusCode.Conflict, hint, error) +fun tooManyRequests( + hint: String, error: TalerErrorCode +): ApiException = apiError(HttpStatusCode.TooManyRequests, hint, error) + fun badRequest( hint: String? = null, error: TalerErrorCode = TalerErrorCode.GENERIC_JSON_INVALID, diff --git a/common/src/main/kotlin/client.kt b/common/src/main/kotlin/client.kt @@ -113,3 +113,5 @@ suspend fun HttpResponse.assertForbidden(err: TalerErrorCode = TalerErrorCode.GE = assertStatus(HttpStatusCode.Forbidden, err) suspend fun HttpResponse.assertNotImplemented(err: TalerErrorCode = TalerErrorCode.END): HttpResponse = assertStatus(HttpStatusCode.NotImplemented, err) +suspend fun HttpResponse.assertTooManyRequests(err: TalerErrorCode): HttpResponse + = assertStatus(HttpStatusCode.TooManyRequests, err) diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1482,9 +1482,11 @@ CREATE FUNCTION tan_challenge_send( IN in_validity_period INT8, IN in_retry_counter INT4, IN in_auth BOOLEAN, + IN in_max_active INT, -- Error status OUT out_no_op BOOLEAN, OUT out_auth_required BOOLEAN, + OUT out_too_many BOOLEAN, -- Success return OUT out_tan_code TEXT, -- TAN code to send, NULL if nothing should be sent OUT out_tan_channel tan_enum, -- TAN channel to use, NULL if nothing should be sent @@ -1506,11 +1508,22 @@ FROM customers WHERE username = in_username AND deleted_at IS NULL; -- Recover expiration date SELECT - (in_timestamp >= expiration_date OR retry_counter <= 0) AND confirmation_date IS NULL + (in_timestamp >= expiration_date OR 0 >= retry_counter) AND confirmation_date IS NULL ,in_timestamp >= retransmission_date AND confirmation_date IS NULL ,NOT in_auth AND op != 'create_token' - ,code, COALESCE(tan_channel, out_tan_channel), COALESCE(tan_info, out_tan_info) -INTO expired, retransmit, out_auth_required, out_tan_code, out_tan_channel, out_tan_info + ,code + ,COALESCE(tan_channel, out_tan_channel) + ,COALESCE(tan_info, out_tan_info) + -- If this is the first time we submit this challenge check there is not too many active challenges + ,retransmission_date = 0 AND ( + SELECT count(*) >= in_max_active + FROM tan_challenges + WHERE customer = account_id + AND retransmission_date != 0 + AND confirmation_date IS NULL + AND expiration_date >= in_timestamp + ) +INTO expired, retransmit, out_auth_required, out_tan_code, out_tan_channel, out_tan_info, out_too_many FROM tan_challenges WHERE challenge_id = in_challenge_id AND customer = account_id; out_no_op = NOT FOUND; IF out_no_op OR out_auth_required THEN