commit 1b8a37e7e2286c0908a6b2953b539cc4219d3b3b
parent 5f53f42899821e4fb7e353ae5ec6b76981ee5dd9
Author: Antoine A <>
Date: Tue, 19 Nov 2024 13:22:04 +0100
bank: rate limit active TAN challenges
Diffstat:
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