commit 4ca04c7cf40c5c9a4fdad6c4987efdd62a6cbfee
parent 77d032f8a1d56c6fd6c01c212459b432c6f20699
Author: Antoine A <>
Date: Wed, 8 Oct 2025 16:41:20 +0200
bank: update 2FA API to match merchant
Diffstat:
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,