libeufin

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

commit 4ca04c7cf40c5c9a4fdad6c4987efdd62a6cbfee
parent 77d032f8a1d56c6fd6c01c212459b432c6f20699
Author: Antoine A <>
Date:   Wed,  8 Oct 2025 16:41:20 +0200

bank: update 2FA API to match merchant

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 6++++++
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 94++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 45++++++++++++++-------------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 34+++++++++++-----------------------
Mbank/src/test/kotlin/bench.kt | 2+-
Mbank/src/test/kotlin/helpers.kt | 4++--
Mdatabase-versioning/libeufin-bank-procedures.sql | 56--------------------------------------------------------
8 files changed, 146 insertions(+), 182 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -151,6 +151,12 @@ data class Challenge( val tan_info: String ) +@Serializable +data class ChallengeRequestResponse( + val solve_expiration: TalerTimestamp, + val earliest_retransmission: TalerTimestamp +) + /** * HTTP response type of successful token refresh. * access_token is the Crockford encoding of the 32 byte diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -751,10 +751,7 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { val uuid = call.uuidPath("CHALLENGE_ID") val res = db.tan.send( uuid = uuid, - code = Tan.genCode(), timestamp = Instant.now(), - retryCounter = TAN_RETRY_COUNTER, - validityPeriod = TAN_VALIDITY_PERIOD, maxActive = MAX_ACTIVE_CHALLENGES ) when (res) { @@ -762,51 +759,64 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { "Challenge $uuid not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) + TanSendResult.Expired -> throw notFound( + "Challenge $uuid expired", + TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED + ) 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] - ?: throw unsupportedTanChannel(res.tanChannel) - val msg = "T-${res.tanCode} is your ${cfg.name} verification code" - val exitValue = withContext(Dispatchers.IO) { - val builder = ProcessBuilder(tanScript.toString(), res.tanInfo) - builder.redirectErrorStream(true) - for ((name, value) in tanEnv) { - builder.environment()[name] = value - } - val process = builder.start() - try { - process.outputWriter().use { it.write(msg) } - process.onExit().await() - } catch (e: Exception) { - process.destroy() - } - val exitValue = process.exitValue() - if (exitValue != 0) { - val out = runCatching { - process.inputStream.use { - reader().readText() - } - }.getOrDefault("") - if (out.isNotEmpty()) { - logger.error("TAN ${res.tanChannel} - ${tanScript}: $out") - } - } - exitValue + TanSendResult.Solved -> call.respond(HttpStatusCode.Gone) + is TanSendResult.Send -> { + val (tanScript, tanEnv) = cfg.tanChannels[res.tanChannel] + ?: throw unsupportedTanChannel(res.tanChannel) + val msg = "T-${res.tanCode} is your ${cfg.name} verification code" + val exitValue = withContext(Dispatchers.IO) { + val builder = ProcessBuilder(tanScript.toString(), res.tanInfo) + builder.redirectErrorStream(true) + for ((name, value) in tanEnv) { + builder.environment()[name] = value + } + val process = builder.start() + try { + process.outputWriter().use { it.write(msg) } + process.onExit().await() + } catch (e: Exception) { + process.destroy() } + val exitValue = process.exitValue() if (exitValue != 0) { - throw apiError( - HttpStatusCode.BadGateway, - "Tan channel script failure with exit value $exitValue", - TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED - ) + val out = runCatching { + process.inputStream.use { + res.tanCode.reader().readText() + } + }.getOrDefault("") + if (out.isNotEmpty()) { + logger.error("TAN ${res.tanChannel} - ${tanScript}: $out") + } } - db.tan.markSent(uuid, Instant.now(), TAN_RETRANSMISSION_PERIOD) + exitValue } - call.respond(HttpStatusCode.NoContent) + if (exitValue != 0) { + throw apiError( + HttpStatusCode.BadGateway, + "Tan channel script failure with exit value $exitValue", + TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED + ) + } + val retransmission = Instant.now() + TAN_RETRANSMISSION_PERIOD + db.tan.markSent(uuid, retransmission) + call.respond(ChallengeRequestResponse( + solve_expiration = res.expiration, + earliest_retransmission = TalerTimestamp(retransmission) + )) + } + is TanSendResult.Success -> { + call.respond(ChallengeRequestResponse( + solve_expiration = res.expiration, + earliest_retransmission = res.retransmission + )) } } } @@ -832,8 +842,8 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { "Too many failed confirmation attempt", TalerErrorCode.BANK_TAN_RATE_LIMITED ) - TanSolveResult.Expired -> throw conflict( - "Challenge expired", + TanSolveResult.Expired -> throw notFound( + "Challenge $uuid expired", TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED ) is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -90,56 +90,89 @@ class TanDAO(private val db: Database) { /** Result of TAN challenge transmission */ sealed interface TanSendResult { - data class Success(val tanInfo: String, val tanChannel: TanChannel, val tanCode: String?): TanSendResult + data class Send( + val tanInfo: String, + val tanChannel: TanChannel, + val tanCode: String, + val expiration: TalerTimestamp + ) + data class Success( + val expiration: TalerTimestamp, + val retransmission: TalerTimestamp + ): TanSendResult + data object Expired: TanSendResult + data object Solved: TanSendResult data object NotFound: TanSendResult data object TooMany: TanSendResult } /** Request TAN challenge transmission */ suspend fun send( - uuid: UUID, - code: String, + uuid: UUID, timestamp: Instant, - retryCounter: Int, - validityPeriod: Duration, maxActive: Int ) = db.serializable( """ - SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info, out_too_many - FROM tan_challenge_send(?,?,?,?,?,?) + SELECT + (confirmation_date IS NOT NULL) as solved + ,retransmission_date + ,expiration_date + ,code + ,tan_channel + ,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(*) >= ? + FROM tan_challenges as o + WHERE c.customer = o.customer + AND retransmission_date != 0 + AND confirmation_date IS NULL + AND expiration_date >= ? + )) AS too_many + FROM tan_challenges as c + WHERE uuid = ? """ ) { - bind(uuid) - bind(code) - bind(timestamp) - bind(TimeUnit.MICROSECONDS.convert(validityPeriod)) - bind(retryCounter) bind(maxActive) - one { + bind(timestamp) + bind(uuid) + oneOrNull { when { - it.getBoolean("out_no_op") -> TanSendResult.NotFound - it.getBoolean("out_too_many") -> TanSendResult.TooMany - else -> TanSendResult.Success( - tanInfo = it.getString("out_tan_info"), - tanChannel = it.getEnum("out_tan_channel"), - tanCode = it.getString("out_tan_code") - ) + it.getBoolean("solved") -> TanSendResult.Solved + it.getBoolean("too_many") -> TanSendResult.TooMany + else -> { + val retransmission = it.getTalerTimestamp("retransmission_date") + val expiration = it.getTalerTimestamp("expiration_date") + if (expiration.instant.isBefore(timestamp)) { + TanSendResult.Expired + } else if (retransmission.instant.isBefore(timestamp)) { + TanSendResult.Send( + tanInfo = it.getString("tan_info"), + tanChannel = it.getEnum("tan_channel"), + tanCode = it.getString("code"), + expiration = expiration + ) + } else { + TanSendResult.Success( + expiration = expiration, + retransmission = retransmission + ) + } + } } - } + } ?: TanSendResult.NotFound } /** Mark TAN challenge transmission */ suspend fun markSent( uuid: UUID, - timestamp: Instant, - retransmissionPeriod: Duration + retransmission: Instant ) = db.serializable( - "SELECT tan_challenge_mark_sent(?,?,?)" + "UPDATE tan_challenges SET retransmission_date = ? WHERE uuid = ?" ) { + bind(retransmission) bind(uuid) - bind(timestamp) - bind(TimeUnit.MICROSECONDS.convert(retransmissionPeriod)) - executeQuery() + executeUpdate() } /** Result of TAN challenge solution */ diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -240,7 +240,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertAcceptedJson<ChallengeResponse>().challenges[0] client.post("/accounts/merchant/challenge/${challenge.challenge_id}") - .assertNoContent() + .assertOk() assertEquals("REDACTED", challenge.tan_info) // Check phone number is hidden val code = tanCode("+12345") client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") { @@ -271,7 +271,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertAcceptedJson<ChallengeResponse>().challenges[0] client.post("/accounts/merchant/challenge/${challenge.challenge_id}") - .assertNoContent() + .assertOk() while (counter > 0) { val error = client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm"){ json { "tan" to "bad code" } @@ -2189,12 +2189,12 @@ class CoreBankTanApiTest { val challenge = it.challenges[0] // Check ok client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") - .assertNoContent() + .assertOk() val code = tanCode("+88") assertNotNull(code) // Check retry client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") - .assertNoContent() + .assertOk() assertNull(tanCode("+88")) // Idempotent patch does nothing client.patchA("/accounts/merchant") { @@ -2204,7 +2204,7 @@ class CoreBankTanApiTest { } } client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") - .assertNoContent() + .assertOk() assertNull(tanCode("+88")) // Change 2fa settings @@ -2218,7 +2218,7 @@ class CoreBankTanApiTest { // Check invalidated client.postA("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") { json { "tan" to code } - }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) + }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) client.patchA("/accounts/merchant") { headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}" json { "is_public" to false } @@ -2243,7 +2243,7 @@ class CoreBankTanApiTest { }.assertAcceptedJson<ChallengeResponse>().challenges[0] suspend fun ApplicationTestBuilder.submit(challenge: Challenge) = client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") - .assertNoContent() + .assertOkJson<ChallengeRequestResponse>() // Start a legitimate challenge and submit it @@ -2305,7 +2305,7 @@ class CoreBankTanApiTest { val challenge = it.challenges[0] val id = challenge.challenge_id client.postA("/accounts/merchant/challenge/$id") - .assertNoContent() + .assertOkJson<ChallengeRequestResponse>() val code = tanCode(challenge.tan_info) // Check bad TAN code @@ -2340,33 +2340,16 @@ class CoreBankTanApiTest { val challenge = it.challenges[0] val id = challenge.challenge_id client.postA("/accounts/merchant/challenge/$id") - .assertNoContent() + .assertOkJson<ChallengeRequestResponse>() - // Check invalidated TODO - /*fillTanInfo("merchant") + // Check invalidated + fillTanInfo("merchant") client.postA("/accounts/merchant/challenge/$id/confirm") { - json { "tan" to tanCode(info) } - }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)*/ + json { "tan" to tanCode(challenge.tan_info) } + }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) client.postA("/accounts/merchant/challenge/$id") - .assertNoContent() - val code = tanCode(challenge.tan_info) - // Idempotent patch does nothing - client.patchA("/accounts/merchant") { - json { - "contact_data" to obj { "phone" to "+88" } - "tan_channel" to "sms" - } - } - client.postA("/accounts/merchant/challenge/$id/confirm") { - json { "tan" to code } - }.assertNoContent() - - // Solved challenge remain solved - fillTanInfo("merchant") - client.postA("/accounts/merchant/challenge/$id/confirm") { - json { "tan" to code } - }.assertNoContent() + .assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) } } } diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -70,18 +70,15 @@ class DatabaseTest { } suspend fun markSent(id: UUID, timestamp: Instant) { - db.tan.markSent(id, timestamp, retransmissionPeriod) + db.tan.markSent(id, timestamp + retransmissionPeriod) } suspend fun send(id: UUID, code: String, timestamp: Instant): String? { return (db.tan.send( id, - code, timestamp, - retryCounter, - validityPeriod, 10 - ) as TanSendResult.Success).tanCode + ) as? TanSendResult.Send)?.tanCode } val now = Instant.now() @@ -95,7 +92,7 @@ class DatabaseTest { // Good code assertIs<TanSolveResult.Success>(db.tan.solve(this, "good-code", now)) // Never resend a confirmed challenge - assertNull(send(this, "new-code", expired)) + assertEquals(TanSendResult.Solved, db.tan.send(this, now, 10)) // Confirmed challenge always ok assertIs<TanSolveResult.Success>(db.tan.solve(this, "good-code", now)) } @@ -110,32 +107,23 @@ class DatabaseTest { assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "bad-code", now)) // Good code fail assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "good-code", now)) - // New code - assertEquals("new-code", send(this, "new-code", now)) - // Good code - assertIs<TanSolveResult.Success>(db.tan.solve(this, "new-code", now)) + // New code + assertIs<TanSendResult.Success>(db.tan.send(this, now, 10)) } - // Check retransmission and expiration + // Check retransmission create("good-code", now).run { // Failed to send retransmit - assertEquals("good-code", send(this, "new-code", now)) + assertIs<TanSendResult.Send>(db.tan.send(this, now, 10)) // Code successfully sent and still valid markSent(this, now) - assertNull(send(this, "new-code", now)) + assertIs<TanSendResult.Success>(db.tan.send(this, now, 10)) // Code is still valid but should be resent - assertEquals("good-code", send(this, "new-code", retransmit)) + assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10)) // Good code fail because expired assertEquals(TanSolveResult.Expired, db.tan.solve(this, "good-code", expired)) - // New code because expired - assertEquals("new-code", send(this, "new-code", expired)) - // Code successfully sent and still valid - markSent(this, expired) - assertNull(send(this, "another-code", expired)) - // Old code no longer works - assertEquals(TanSolveResult.BadCode, db.tan.solve(this, "good-code", expired)) - // New code works - assertIs<TanSolveResult.Success>(db.tan.solve(this, "new-code", expired)) + // No code because expired + assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10)) } }} } \ No newline at end of file diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt @@ -357,7 +357,7 @@ class Bench { } }.assertAcceptedJson<ChallengeResponse>() val challenge = res.challenges[0] - client.postA("/accounts/account_bench_$it/challenge/${challenge.challenge_id}").assertNoContent() + client.postA("/accounts/account_bench_$it/challenge/${challenge.challenge_id}").assertOk() val code = tanCode(challenge.tan_info) Pair(challenge.challenge_id, code) } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -360,10 +360,10 @@ suspend fun HttpResponse.assertChallenge( if (res.combi_and) { for (challenge in res.challenges) { - call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertNoContent() + call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk() } } else { - call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertNoContent() + call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk() } check(res) diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1595,62 +1595,6 @@ INSERT INTO libeufin_nexus.initiated_outgoing_transactions ( CALL stats_register_payment('cashout', NULL, in_amount_debit, in_amount_credit); END $$; -CREATE FUNCTION tan_challenge_send( - IN in_uuid UUID, - IN in_code TEXT, -- New code to use if the old code expired - IN in_timestamp INT8, - IN in_validity_period INT8, - IN in_retry_counter INT4, - IN in_max_active INT, - -- Error status - OUT out_no_op 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 - OUT out_tan_info TEXT -- TAN info to use, NULL if nothing should be sent -) -LANGUAGE plpgsql as $$ -DECLARE -expired BOOLEAN; -retransmit BOOLEAN; -BEGIN --- Recover expiration date -SELECT - (in_timestamp >= expiration_date OR 0 >= retry_counter) AND confirmation_date IS NULL - ,in_timestamp >= retransmission_date AND confirmation_date IS NULL - ,code - ,tan_channel - ,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 as o - WHERE c.customer = o.customer - AND retransmission_date != 0 - AND confirmation_date IS NULL - AND expiration_date >= in_timestamp - ) -INTO expired, retransmit, out_tan_code, out_tan_channel, out_tan_info, out_too_many -FROM tan_challenges as c -WHERE uuid = in_uuid; - -out_no_op = NOT FOUND; -IF out_no_op THEN RETURN; END IF; - -IF expired THEN - UPDATE tan_challenges SET - code = in_code - ,expiration_date = in_timestamp + in_validity_period - ,retry_counter = in_retry_counter - WHERE uuid = in_uuid; - out_tan_code = in_code; -ELSIF NOT retransmit THEN - out_tan_code = NULL; -END IF; -END $$; -COMMENT ON FUNCTION tan_challenge_send IS 'Get the challenge to send, return NULL if nothing should be sent'; - CREATE FUNCTION tan_challenge_mark_sent ( IN in_uuid UUID, IN in_timestamp INT8,