commit d3ef68d4dd63c8b9e3dd59d40c70ff88d547b086
parent 8b54f4011b71360ba7e1afeab11af4bc5270c7f5
Author: Antoine A <>
Date: Wed, 8 Oct 2025 15:22:46 +0200
bank: new 2FA API
Diffstat:
25 files changed, 977 insertions(+), 683 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -39,6 +39,6 @@ const val MAX_TOKEN_CREATION_ATTEMPTS: Int = 5
const val MAX_ACTIVE_CHALLENGES: Int = 5
// API version
-const val COREBANK_API_VERSION: String = "9:0:0"
+const val COREBANK_API_VERSION: String = "10:0:0"
const val CONVERSION_API_VERSION: String = "2:0:1"
const val INTEGRATION_API_VERSION: String = "5:0:5"
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -139,14 +139,16 @@ sealed class Option<out T> {
}
@Serializable
-data class TanChallenge(
- val challenge_id: Long
+data class ChallengeResponse(
+ val challenges: List<Challenge>,
+ val combi_and: Boolean
)
@Serializable
-data class TanTransmission(
- val tan_info: String,
- val tan_channel: TanChannel
+data class Challenge(
+ val challenge_id: String,
+ val tan_channel: TanChannel,
+ val tan_info: String
)
/**
@@ -195,11 +197,24 @@ data class RegisterAccountRequest(
val payto_uri: Payto? = null,
val debit_threshold: TalerAmount? = null,
val tan_channel: TanChannel? = null,
+ val tan_channels: Set<TanChannel>? = null,
val conversion_rate_class_id: Long? = null
) {
init {
if (!USERNAME_REGEX.matches(username))
throw badRequest("username '$username' is malformed, must match [a-zA-Z0-9\\-\\._~]{1,126}")
+ else if (tan_channel != null && tan_channels != null)
+ throw badRequest("you must only use either tan_channel or tan_channels")
+ }
+
+ val channels: Set<TanChannel> get() {
+ if (tan_channels != null) {
+ return tan_channels
+ } else if (tan_channel != null) {
+ return setOf(tan_channel)
+ } else {
+ return emptySet()
+ }
}
companion object {
@@ -223,9 +238,29 @@ data class AccountReconfiguration(
val is_public: Boolean? = null,
val debit_threshold: TalerAmount? = null,
val tan_channel: Option<TanChannel?> = Option.None,
+ val tan_channels: Option<Set<TanChannel>> = Option.None,
val is_taler_exchange: Boolean? = null,
val conversion_rate_class_id: Option<Long?> = Option.None
-)
+) {
+ init {
+ if (tan_channel.isSome() && tan_channels.isSome())
+ throw badRequest("you must only use either tan_channel or tan_channels")
+ }
+
+ val channels: Option<Set<TanChannel>> get() {
+ if (tan_channels.isSome()) {
+ return tan_channels
+ } else if (tan_channel is Option.Some) {
+ if (tan_channel.value == null) {
+ return Option.Some(emptySet())
+ } else {
+ return Option.Some(setOf(tan_channel.value))
+ }
+ } else {
+ return Option.None
+ }
+ }
+}
/**
* Type expected at POST /accounts/{USERNAME}/token
@@ -271,6 +306,8 @@ data class MonitorWithConversion(
override val talerOutVolume: TalerAmount
) : MonitorResponse
+typealias Tans = List<Pair<TanChannel, String>>
+
/**
* Convenience type to get bank account information
* from/to the database.
@@ -279,8 +316,25 @@ data class BankInfo(
val username: String,
val payto: String,
val bankAccountId: Long,
- val isTalerExchange: Boolean
-)
+ val isTalerExchange: Boolean,
+ override val phone: String?,
+ override val email: String?,
+ override val channels: Set<TanChannel>
+): TanInfo
+
+interface TanInfo {
+ val phone: String?
+ val email: String?
+ val channels: Set<TanChannel>
+
+ val mfa: Tans get() = channels.map { channel ->
+ val info = when (channel) {
+ TanChannel.sms -> phone
+ TanChannel.email -> email
+ }
+ Pair(channel, requireNotNull(info))
+ }
+}
// Allowed values for cashout TAN channels.
enum class TanChannel {
@@ -430,6 +484,7 @@ data class AccountData(
val contact_data: ChallengeContactData? = null,
val cashout_payto_uri: String? = null,
val tan_channel: TanChannel? = null,
+ val tan_channels: Set<TanChannel> = emptySet(),
val is_public: Boolean,
val is_taler_exchange: Boolean,
val is_locked: Boolean,
@@ -589,14 +644,11 @@ data class GlobalCashoutInfo(
@Serializable
data class CashoutStatusResponse(
- val status: CashoutStatus,
val amount_debit: TalerAmount,
val amount_credit: TalerAmount,
val subject: String,
val creation_time: TalerTimestamp,
- val confirmation_time: TalerTimestamp? = null,
- val tan_channel: TanChannel? = null,
- val tan_info: String? = null
+ val confirmation_time: TalerTimestamp? = null // TODO update doc
)
@Serializable
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -30,16 +30,13 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory
import tech.libeufin.bank.*
import tech.libeufin.bank.auth.*
-import tech.libeufin.bank.db.AbortResult
+import tech.libeufin.bank.db.*
import tech.libeufin.bank.db.AccountDAO.*
import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult
-import tech.libeufin.bank.db.Database
-import tech.libeufin.bank.db.TanDAO.TanSendResult
-import tech.libeufin.bank.db.TanDAO.TanSolveResult
+import tech.libeufin.bank.db.TanDAO.*
import tech.libeufin.bank.db.TokenDAO.TokenCreationResult
import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
-import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult
-import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult
+import tech.libeufin.bank.db.WithdrawalDAO.*
import tech.libeufin.bank.db.ConversionDAO.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.*
@@ -129,7 +126,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
description = req.description,
is2fa = existingToken != null || challenge != null
)) {
- TokenCreationResult.TanRequired -> call.respondChallenge(db, Operation.create_token, req)
+ TokenCreationResult.TanRequired -> call.respondMfa(db, Operation.create_token)
TokenCreationResult.Success -> call.respond(
TokenSuccessResponse(
access_token = "$TOKEN_PREFIX$token",
@@ -185,6 +182,9 @@ suspend fun createAccount(
TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
)
+ // Check tan channels has the corresponding info
+ val channels = req.channels
+
if (!isAdmin) {
if (req.debit_threshold != null)
throw conflict(
@@ -198,27 +198,29 @@ suspend fun createAccount(
TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS
)
- if (req.tan_channel != null)
+ if (channels.isNotEmpty())
throw conflict(
"only admin account can enable 2fa on creation",
TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL
)
+ }
- } else if (req.tan_channel != null) {
- if (cfg.tanChannels[req.tan_channel] == null) {
- throw unsupportedTanChannel(req.tan_channel)
- }
- val missing = when (req.tan_channel) {
- TanChannel.sms -> req.contact_data?.phone?.get() == null
- TanChannel.email -> req.contact_data?.email?.get() == null
+ for (channel in channels) {
+ if (cfg.tanChannels[channel] == null) {
+ throw unsupportedTanChannel(channel)
+ }
+ val info = when (channel) {
+ TanChannel.sms -> req.contact_data?.phone?.get()
+ TanChannel.email -> req.contact_data?.email?.get()
}
- if (missing)
+ if (info == null) {
throw conflict(
- "missing info for tan channel ${req.tan_channel}",
+ "missing info for tan channel $channel",
TalerErrorCode.BANK_MISSING_TAN_INFO
)
+ }
}
-
+
if (req.username == "exchange" && !req.is_taler_exchange)
throw conflict(
"'exchange' account must be a taler exchange account",
@@ -240,7 +242,7 @@ suspend fun createAccount(
maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit,
bonus = if (!req.is_taler_exchange) cfg.registrationBonus
else TalerAmount(0, 0, cfg.regionalCurrency),
- tanChannel = req.tan_channel,
+ tanChannels = req.channels,
checkPaytoIdempotent = req.payto_uri != null,
pwCrypto = cfg.pwCrypto,
conversionRateClassId = req.conversion_rate_class_id
@@ -281,9 +283,7 @@ suspend fun patchAccount(
req: AccountReconfiguration,
username: String,
isAdmin: Boolean,
- is2fa: Boolean,
- channel: TanChannel? = null,
- info: String? = null
+ is2fa: Boolean
): AccountPatchResult {
req.debit_threshold?.run { cfg.checkRegionalCurrency(this) }
@@ -299,26 +299,23 @@ suspend fun patchAccount(
TalerErrorCode.END
)
- if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) {
- throw unsupportedTanChannel(req.tan_channel.value)
- }
+ val channels = req.channels.get()
+ if (channels != null) {
+ for (channel in channels) {
+ if (cfg.tanChannels[channel] == null) {
+ throw unsupportedTanChannel(channel)
+ }
+ }
+ }
+
return db.account.reconfig(
username = username,
- name = req.name,
- cashoutPayto = req.cashout_payto_uri,
- email = req.contact_data?.email ?: Option.None,
- phone = req.contact_data?.phone ?: Option.None,
- tan_channel = req.tan_channel,
- isPublic = req.is_public,
- debtLimit = req.debit_threshold,
+ req = req,
isAdmin = isAdmin,
is2fa = is2fa,
- faChannel = channel,
- faInfo = info,
allowEditName = cfg.allowEditName,
- allowEditCashout = cfg.allowEditCashout,
- conversionRateClassId = req.conversion_rate_class_id
+ allowEditCashout = cfg.allowEditCashout
)
}
@@ -354,7 +351,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
requireAdmin = !cfg.allowAccountDeletion
) {
delete("/accounts/{USERNAME}") {
- val challenge = call.checkChallenge(db, Operation.account_delete)
+ val (_, challenge) = call.receiveChallenge(db, Operation.account_delete, Unit)
// Not deleting reserved names.
if (RESERVED_ACCOUNTS.contains(call.pathUsername))
@@ -374,19 +371,28 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
"Account balance is not zero.",
TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
)
- AccountDeletionResult.TanRequired -> call.respondChallenge(db, Operation.account_delete, Unit)
+ AccountDeletionResult.TanRequired -> call.respondMfa(db, Operation.account_delete)
AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent)
}
}
}
auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat, allowAdmin = true) {
patch("/accounts/{USERNAME}") {
- val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
- val res = patchAccount(db, cfg, req, call.pathUsername, call.isAdmin, challenge != null, challenge?.channel, challenge?.info)
+ val (req, pendingValidation) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
+
+ if (pendingValidation != null && pendingValidation.isNotEmpty()) {
+ return@patch call.respondValidation(db, Operation.account_reconfig, pendingValidation)
+ }
+
+ val res = patchAccount(db, cfg, req, call.pathUsername, call.isAdmin, pendingValidation != null)
when (res) {
AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
- is AccountPatchResult.TanRequired -> {
- call.respondChallenge(db, Operation.account_reconfig, req, res.channel, res.info)
+ is AccountPatchResult.Challenges -> {
+ if (res.validations.isNotEmpty()) {
+ call.respondValidation(db, Operation.account_reconfig, res.validations)
+ } else {
+ call.respondMfa(db, Operation.account_reconfig)
+ }
}
AccountPatchResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
AccountPatchResult.NonAdminName -> throw conflict(
@@ -426,7 +432,7 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
when (db.account.reconfigPassword(call.pathUsername, newPassword, req.old_password, call.isAdmin || challenge != null, cfg.pwCrypto)) {
AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent)
- AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req)
+ AccountPatchAuthResult.TanRequired -> call.respondMfa(db, Operation.account_auth_reconfig)
AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(call.pathUsername)
AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
"old password does not match",
@@ -510,7 +516,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
when (res) {
BankTransactionResult.UnknownDebtor -> throw unknownAccount(call.pathUsername)
BankTransactionResult.TanRequired -> {
- call.respondChallenge(db, Operation.bank_transaction, req)
+ call.respondMfa(db, Operation.bank_transaction)
}
BankTransactionResult.BothPartySame -> throw conflict(
"Wire transfer attempted with credit and debit party being the same bank account",
@@ -627,7 +633,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
TalerErrorCode.BANK_UNKNOWN_CREDITOR
)
WithdrawalConfirmationResult.TanRequired -> {
- call.respondChallenge(db, Operation.withdrawal, req)
+ call.respondMfa(db, Operation.withdrawal)
}
WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
}
@@ -702,7 +708,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio
TalerErrorCode.BANK_CONFIRM_INCOMPLETE
)
CashoutCreationResult.TanRequired -> {
- call.respondChallenge(db, Operation.cashout, req)
+ call.respondMfa(db, Operation.cashout)
}
is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id))
}
@@ -741,112 +747,96 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio
}
private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) {
- optAuth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) {
- post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
- val id = call.longPath("CHALLENGE_ID")
- val res = db.tan.send(
- id = id,
- username = call.pathUsername,
- code = Tan.genCode(),
- timestamp = Instant.now(),
- retryCounter = TAN_RETRY_COUNTER,
- validityPeriod = TAN_VALIDITY_PERIOD,
- isAuth = call.isAuthenticated,
- maxActive = MAX_ACTIVE_CHALLENGES
+ post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
+ 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) {
+ TanSendResult.NotFound -> throw notFound(
+ "Challenge $uuid not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
)
- when (res) {
- TanSendResult.NotFound -> throw notFound(
- "Challenge $id not found",
- 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]
- ?: 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.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) {
- throw apiError(
- HttpStatusCode.BadGateway,
- "Tan channel script failure with exit value $exitValue",
- TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
- )
+ val out = runCatching {
+ process.inputStream.use {
+ reader().readText()
+ }
+ }.getOrDefault("")
+ if (out.isNotEmpty()) {
+ logger.error("TAN ${res.tanChannel} - ${tanScript}: $out")
+ }
}
- db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD)
+ exitValue
}
- val tan_info = if (call.isAuthenticated) {
- res.tanInfo
- } else {
- "REDACTED"
+ if (exitValue != 0) {
+ throw apiError(
+ HttpStatusCode.BadGateway,
+ "Tan channel script failure with exit value $exitValue",
+ TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
+ )
}
- call.respond(TanTransmission(
- tan_info = tan_info,
- tan_channel = res.tanChannel
- ))
+ db.tan.markSent(uuid, Instant.now(), TAN_RETRANSMISSION_PERIOD)
}
+ call.respond(HttpStatusCode.NoContent)
}
}
- post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
- val id = call.longPath("CHALLENGE_ID")
- val req = call.receive<ChallengeSolve>()
- val code = req.tan.removePrefix("T-")
- val res = db.tan.solve(
- id = id,
- username = call.pathUsername,
- code = code,
- timestamp = Instant.now(),
- isAuth = call.isAuthenticated
+ }
+ post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
+ val uuid = call.uuidPath("CHALLENGE_ID")
+ val req = call.receive<ChallengeSolve>()
+ val code = req.tan.removePrefix("T-")
+ val res = db.tan.solve(
+ uuid = uuid,
+ code = code,
+ timestamp = Instant.now()
+ )
+ when (res) {
+ TanSolveResult.NotFound -> throw notFound(
+ "Challenge $uuid not found",
+ TalerErrorCode.BANK_CHALLENGE_NOT_FOUND
)
- when (res) {
- TanSolveResult.NotFound -> throw notFound(
- "Challenge $id not found",
- TalerErrorCode.BANK_CHALLENGE_NOT_FOUND
- )
- TanSolveResult.BadCode -> throw conflict(
- "Incorrect TAN code",
- TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
- )
- TanSolveResult.NoRetry -> throw tooManyRequests(
- "Too many failed confirmation attempt",
- TalerErrorCode.BANK_TAN_RATE_LIMITED
- )
- TanSolveResult.Expired -> throw conflict(
- "Challenge expired",
- TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
- )
- TanSolveResult.AuthRequired -> throw missingAuth()
- is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
+ TanSolveResult.BadCode -> throw conflict(
+ "Incorrect TAN code",
+ TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
+ )
+ TanSolveResult.NoRetry -> throw tooManyRequests(
+ "Too many failed confirmation attempt",
+ TalerErrorCode.BANK_TAN_RATE_LIMITED
+ )
+ TanSolveResult.Expired -> throw conflict(
+ "Challenge expired",
+ TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
+ )
+ is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent)
}
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt
@@ -1,117 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2023-2025 Taler Systems S.A.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-package tech.libeufin.bank.auth
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.plugins.*
-import kotlinx.serialization.json.Json
-import tech.libeufin.bank.*
-import tech.libeufin.bank.db.Database
-import tech.libeufin.bank.db.TanDAO.Challenge
-import tech.libeufin.common.X_CHALLENGE_ID
-import tech.libeufin.common.SECURE_RNG
-import java.text.DecimalFormat
-import java.time.Instant
-
-/**
- * Generate a TAN challenge for an [op] request with [body] and
- * respond to the HTTP request with a TAN challenge.
- *
- * If [channel] and [info] are present, they will be used
- * to send the TAN code, otherwise defaults will be used.
- */
-suspend inline fun <reified B> ApplicationCall.respondChallenge(
- db: Database,
- op: Operation,
- body: B,
- channel: TanChannel? = null,
- info: String? = null
-) {
- val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), body)
- val code = Tan.genCode()
- val id = db.tan.new(
- username = pathUsername,
- op = op,
- body = json,
- code = code,
- timestamp = Instant.now(),
- retryCounter = TAN_RETRY_COUNTER,
- validityPeriod = TAN_VALIDITY_PERIOD,
- channel = channel,
- info = info
- )
- respond(
- status = HttpStatusCode.Accepted,
- message = TanChallenge(id)
- )
-}
-
-/**
- * Retrieve a confirmed challenge and its body for [op] from the database
- * if the challenge header is defined, otherwise extract the HTTP body.
- */
-suspend inline fun <reified B> ApplicationCall.receiveChallenge(
- db: Database,
- op: Operation,
- default: B? = null
-): Pair<B, Challenge?> {
- val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull()
- return if (id != null) {
- val challenge = db.tan.challenge(id, pathUsername, op)!!
- Pair(Json.decodeFromString(challenge.body), challenge)
- } else {
- if (default != null) {
- val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull()
- if (contentLenght == 0) {
- return Pair(default, null)
- }
- }
- Pair(this.receive(), null)
- }
-}
-
-/**
- * Retrieve a confirmed challenge body for [op] if the challenge header is defined
- */
-suspend fun ApplicationCall.checkChallenge(
- db: Database,
- op: Operation
-): Challenge? {
- val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull()
- return if (id != null) {
- db.tan.challenge(id, pathUsername, op)!!
- } else {
- null
- }
-}
-
-object Tan {
- private val CODE_FORMAT = DecimalFormat("00000000")
-
- /** Generate a secure random TAN code */
- fun genCode(): String {
- val rand = SECURE_RNG.get().nextInt(100000000)
- val code = CODE_FORMAT.format(rand)
- return code
- }
-}
-
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt
@@ -0,0 +1,182 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023-2025 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+package tech.libeufin.bank.auth
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.plugins.*
+import kotlinx.serialization.json.Json
+import tech.libeufin.bank.*
+import tech.libeufin.bank.db.*
+import tech.libeufin.bank.db.TanDAO.*
+import tech.libeufin.common.*
+import tech.libeufin.common.crypto.*
+import tech.libeufin.common.api.*
+import java.text.DecimalFormat
+import java.time.Instant
+import java.util.UUID
+
+private suspend fun ApplicationCall.respondChallenges(
+ db: Database,
+ op: Operation,
+ tans: Tans
+): List<Challenge> {
+ val (hbody, salt) = CryptoUtil.mfaBodyHashCreate(this.rawBody)
+ val challenges = mutableListOf<Challenge>()
+
+ for ((channel, info) in tans) {
+ val code = Tan.genCode()
+ val uuid = db.tan.new(
+ hbody = hbody,
+ salt = salt,
+ username = pathUsername,
+ op = op,
+ code = code,
+ timestamp = Instant.now(),
+ retryCounter = TAN_RETRY_COUNTER,
+ validityPeriod = TAN_VALIDITY_PERIOD,
+ tanChannel = channel,
+ tanInfo = info
+ )
+ challenges.add(Challenge(
+ challenge_id = uuid.toString(),
+ tan_channel = channel,
+ tan_info = info
+ ))
+ }
+ return challenges
+}
+
+/**
+ * Generate a TAN challenge for an [op] request with [body] and
+ * respond to the HTTP request with a TAN challenge.
+ *
+ * If [channel] and [info] are present, they will be used
+ * to send the TAN code, otherwise defaults will be used.
+ */
+suspend fun ApplicationCall.respondMfa(
+ db: Database,
+ op: Operation
+) {
+ val info = this.bankInfo(db)
+ var challenges = respondChallenges(db, op, info.mfa)
+ if (op == Operation.create_token) {
+ challenges = challenges.map { it.copy(tan_info="REDACTED") }
+ }
+ respond(
+ status = HttpStatusCode.Accepted,
+ message = ChallengeResponse(
+ challenges = challenges,
+ combi_and = false
+ )
+ )
+}
+
+suspend fun ApplicationCall.respondValidation(
+ db: Database,
+ op: Operation,
+ tans: Tans
+) {
+ val challenges = respondChallenges(db, op, tans)
+ respond(
+ status = HttpStatusCode.Accepted,
+ message = ChallengeResponse(
+ challenges = challenges,
+ combi_and = true
+ )
+ )
+}
+
+/**
+ * Retrieve a confirmed challenge and its body for [op] from the database
+ * if the challenge header is defined, otherwise extract the HTTP body.
+ */
+suspend inline fun <reified B> ApplicationCall.receiveChallenge(
+ db: Database,
+ op: Operation,
+ default: B? = null
+): Pair<B, Tans?> {
+ // Parse body
+ val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull()
+ val body: B = if (contentLenght == 0 && default != null) {
+ default
+ } else {
+ this.receive()
+ }
+
+ // Check if challenges are used
+ val ids = request.headers[TALER_CHALLENGE_IDS]
+ if (ids == null) return Pair(body, null)
+
+ // List validated challenges
+ val uuids = ids.split(',').map { UUID.fromString(it) }
+ val challenges = db.tan.challenge(uuids)
+
+ val validated = challenges.mapNotNull { challenge ->
+ if (challenge.op != op) {
+ throw forbidden("Challenge is for a different operation body")
+ } else if (!CryptoUtil.mfaBodyHashCheck(this.rawBody, challenge.hbody, challenge.salt)) {
+ throw forbidden("Challenge is for a different request body")
+ } else if (challenge.confirmed) {
+ Pair(challenge.channel, challenge.info)
+ } else {
+ null
+ }
+ }
+
+ if (validated.isEmpty()) return Pair(body, null)
+
+ // CHeck if challenges are solved
+ val bankInfo = this.bankInfo(db)
+
+ // Account reconfig require mfa & new TAN validation
+ if (op == Operation.account_reconfig) {
+ val req = body as AccountReconfiguration
+
+ val requiredValidation = req.requiredValidation(bankInfo)
+ if (requiredValidation.all { validated.contains(it) }) {
+ return Pair(body, emptyList())
+ } else if (bankInfo.mfa.any { validated.contains(it) }) {
+ return Pair(body, requiredValidation)
+ } else {
+ return Pair(body, null)
+ }
+ } else {
+ // Check mfa
+ if (bankInfo.mfa.any { validated.contains(it) }) {
+ return Pair(body, emptyList())
+ } else {
+ return Pair(body, null)
+ }
+ }
+}
+
+object Tan {
+ private val CODE_FORMAT = DecimalFormat("00000000")
+
+ /** Generate a secure random TAN code */
+ fun genCode(): String {
+ val rand = SECURE_RNG.get().nextInt(100000000)
+ val code = CODE_FORMAT.format(rand)
+ return code
+ }
+}
+
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt
@@ -94,7 +94,7 @@ class EditAccount : TalerCmd("edit-account") {
AccountPatchResult.NonAdminDebtLimit,
AccountPatchResult.NonAdminConversionRateClass,
AccountPatchResult.UnknownConversionClass,
- is AccountPatchResult.TanRequired ->
+ is AccountPatchResult.Challenges ->
throw IllegalStateException("Those error can never happen $res")
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -51,7 +51,7 @@ class AccountDAO(private val db: Database) {
isTalerExchange: Boolean,
maxDebt: TalerAmount,
bonus: TalerAmount,
- tanChannel: TanChannel?,
+ tanChannels: Set<TanChannel>,
// Whether to check [internalPaytoUri] for idempotency
checkPaytoIdempotent: Boolean,
pwCrypto: PwCrypto,
@@ -63,7 +63,7 @@ class AccountDAO(private val db: Database) {
AND email IS NOT DISTINCT FROM ?
AND phone IS NOT DISTINCT FROM ?
AND cashout_payto IS NOT DISTINCT FROM ?
- AND tan_channel IS NOT DISTINCT FROM ?::tan_enum
+ AND tan_channels = sort_uniq(?::tan_enum[])
AND (NOT ? OR internal_payto=?)
AND is_public=?
AND is_taler_exchange=?
@@ -79,7 +79,7 @@ class AccountDAO(private val db: Database) {
bind(email)
bind(phone)
bind(cashoutPayto?.simple())
- bind(tanChannel)
+ bind(tanChannels.toTypedArray())
bind(checkPaytoIdempotent)
bind(internalPayto.canonical)
bind(isPublic)
@@ -125,8 +125,8 @@ class AccountDAO(private val db: Database) {
,email
,phone
,cashout_payto
- ,tan_channel
- ) VALUES (?, ?, ?, ?, ?, ?, ?::tan_enum)
+ ,tan_channels
+ ) VALUES (?, ?, ?, ?, ?, ?, sort_uniq(?::tan_enum[]))
RETURNING customer_id
"""
) {
@@ -136,7 +136,7 @@ class AccountDAO(private val db: Database) {
bind(email)
bind(phone)
bind(cashoutPayto?.simple())
- bind(tanChannel)
+ bind(tanChannels.toTypedArray())
one { it.getLong("customer_id") }
}
@@ -238,28 +238,29 @@ class AccountDAO(private val db: Database) {
data object NonAdminConversionRateClass: AccountPatchResult
data object UnknownConversionClass: AccountPatchResult
data object MissingTanInfo: AccountPatchResult
- data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult
+ data class Challenges(val validations: Tans): AccountPatchResult
data object Success: AccountPatchResult
}
/** Change account [username] information */
suspend fun reconfig(
username: String,
- name: String?,
- cashoutPayto: Option<IbanPayto?>,
- phone: Option<String?>,
- email: Option<String?>,
- tan_channel: Option<TanChannel?>,
- isPublic: Boolean?,
- debtLimit: TalerAmount?,
+ req: AccountReconfiguration,
isAdmin: Boolean,
is2fa: Boolean,
- faChannel: TanChannel?,
- faInfo: String?,
allowEditName: Boolean,
- allowEditCashout: Boolean,
- conversionRateClassId: Option<Long?>
+ allowEditCashout: Boolean
): AccountPatchResult = db.serializableTransaction { conn ->
+ val name = req.name
+ val cashoutPayto = req.cashout_payto_uri
+ val email = req.contact_data?.email ?: Option.None
+ val phone = req.contact_data?.phone ?: Option.None
+ val tan_channels = req.channels
+ val isPublic = req.is_public
+ val debtLimit = req.debit_threshold
+ val conversionRateClassId = req.conversion_rate_class_id
+ val channels = req.channels.get()
+
val checkName = !isAdmin && !allowEditName && name != null
val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome()
val checkDebtLimit = !isAdmin && debtLimit != null
@@ -267,19 +268,19 @@ class AccountDAO(private val db: Database) {
data class CurrentAccount(
val id: Long,
- val channel: TanChannel?,
- val email: String?,
- val phone: String?,
+ override val channels: Set<TanChannel>,
+ override val email: String?,
+ override val phone: String?,
val name: String,
val cashoutPayTo: String?,
val debtLimit: TalerAmount,
val conversionRateClassId: Long?
- )
+ ): TanInfo
// Get user ID and current data
val curr = conn.withStatement("""
SELECT
- customer_id, tan_channel, phone, email, name, cashout_payto
+ customer_id, tan_channels, phone, email, name, cashout_payto
,(max_debt).val AS max_debt_val
,(max_debt).frac AS max_debt_frac
,conversion_rate_class_id
@@ -292,7 +293,7 @@ class AccountDAO(private val db: Database) {
oneOrNull {
CurrentAccount(
id = it.getLong("customer_id"),
- channel = it.getOptEnum<TanChannel>("tan_channel"),
+ channels = it.getEnumSet<TanChannel>("tan_channels"),
phone = it.getString("phone"),
email = it.getString("email"),
name = it.getString("name"),
@@ -303,22 +304,23 @@ class AccountDAO(private val db: Database) {
} ?: return@serializableTransaction AccountPatchResult.UnknownAccount
}
- // Patched TAN channel
- val patchChannel = tan_channel.get()
- // TAN channel after the PATCH
- val newChannel = patchChannel ?: curr.channel
- // Patched TAN info
- val patchInfo = when (newChannel) {
- TanChannel.sms -> phone.get()
- TanChannel.email -> email.get()
- null -> null
- }
- // TAN info after the PATCH
- val newInfo = patchInfo ?: when (newChannel) {
- TanChannel.sms -> curr.phone
- TanChannel.email -> curr.email
- null -> null
+ // Check tan info
+ val validations = req.requiredValidation(curr)
+
+ // Check performed 2fa check
+ if (!isAdmin && !is2fa) {
+ // Check if mfa is required
+ if (curr.channels.isNotEmpty()) {
+ return@serializableTransaction AccountPatchResult.Challenges(emptyList())
+ }
+
+ // Check if validation is required
+ if (validations.isNotEmpty()) {
+ return@serializableTransaction AccountPatchResult.Challenges(validations)
+ }
}
+
+
// Cashout payto without a receiver-name
val simpleCashoutPayto = cashoutPayto.get()?.simple()
@@ -331,29 +333,6 @@ class AccountDAO(private val db: Database) {
return@serializableTransaction AccountPatchResult.NonAdminDebtLimit
if (checkConversionRateClass && conversionRateClassId.get() != curr.conversionRateClassId)
return@serializableTransaction AccountPatchResult.NonAdminConversionRateClass
- if (patchChannel != null && newInfo == null)
- return@serializableTransaction AccountPatchResult.MissingTanInfo
-
- // Tan channel verification
- if (!isAdmin) {
- // Check performed 2fa check
- if (curr.channel != null && !is2fa) {
- // Perform challenge with current settings
- return@serializableTransaction AccountPatchResult.TanRequired(channel = null, info = null)
- }
- // If channel or info changed and the 2fa challenge is performed with old settings perform a new challenge with new settings
- if ((patchChannel != null && patchChannel != faChannel) || (patchInfo != null && patchInfo != faInfo)) {
- return@serializableTransaction AccountPatchResult.TanRequired(channel = newChannel, info = newInfo)
- }
- }
-
- // Invalidate current challenges
- if (patchChannel != null || patchInfo != null) {
- conn.withStatement("UPDATE tan_challenges SET expiration_date=0 WHERE customer=?") {
- bind(curr.id)
- executeUpdate()
- }
- }
try {
// Update bank info
@@ -387,7 +366,7 @@ class AccountDAO(private val db: Database) {
cashoutPayto.some { yield("cashout_payto=?") }
phone.some { yield("phone=?") }
email.some { yield("email=?") }
- tan_channel.some { yield("tan_channel=?::tan_enum") }
+ tan_channels.some { yield("tan_channels=sort_uniq(?::tan_enum[])") }
name?.let { yield("name=?") }
},
"WHERE customer_id = ?"
@@ -395,11 +374,19 @@ class AccountDAO(private val db: Database) {
cashoutPayto.some { bind(simpleCashoutPayto) }
phone.some { bind(it) }
email.some { bind(it) }
- tan_channel.some { bind(it?.name) }
+ tan_channels.some { bind(it.toTypedArray()) }
name?.let { bind(it) }
bind(curr.id)
}
+ // Invalidate current challenges
+ if (validations.isNotEmpty()) {
+ conn.withStatement("UPDATE tan_challenges SET expiration_date=0 WHERE customer=?") {
+ bind(curr.id)
+ executeUpdate()
+ }
+ }
+
AccountPatchResult.Success
}
@@ -421,7 +408,7 @@ class AccountDAO(private val db: Database) {
pwCrypto: PwCrypto
): AccountPatchAuthResult = db.serializableTransaction { conn ->
val (customerId, currentPwh, tanRequired) = conn.withStatement("""
- SELECT customer_id, password_hash, (NOT ? AND tan_channel IS NOT NULL)
+ SELECT customer_id, password_hash, NOT ? AND cardinality(tan_channels) > 0
FROM customers WHERE username=? AND deleted_at IS NULL
""") {
bind(is2fa)
@@ -466,7 +453,10 @@ class AccountDAO(private val db: Database) {
bank_account_id,
internal_payto,
is_taler_exchange,
- name
+ name,
+ tan_channels,
+ email,
+ phone
FROM bank_accounts
JOIN customers ON customer_id=owning_customer_id
WHERE username=? AND deleted_at IS NULL
@@ -478,7 +468,10 @@ class AccountDAO(private val db: Database) {
username = it.getString("username"),
payto = it.getBankPayto("internal_payto", "name", db.ctx),
bankAccountId = it.getLong("bank_account_id"),
- isTalerExchange = it.getBoolean("is_taler_exchange")
+ isTalerExchange = it.getBoolean("is_taler_exchange"),
+ channels = it.getEnumSet<TanChannel>("tan_channels"),
+ phone = it.getString("phone"),
+ email = it.getString("email")
)
Triple(info, it.getString("password_hash"), it.getInt("token_creation_counter"))
}
@@ -513,10 +506,13 @@ class AccountDAO(private val db: Database) {
suspend fun bankInfo(username: String): BankInfo? = db.serializable(
"""
SELECT
- bank_account_id
- ,internal_payto
- ,name
- ,is_taler_exchange
+ bank_account_id,
+ internal_payto,
+ name,
+ is_taler_exchange,
+ tan_channels,
+ email,
+ phone
FROM bank_accounts
JOIN customers ON customer_id=owning_customer_id
WHERE username=?
@@ -528,7 +524,10 @@ class AccountDAO(private val db: Database) {
username = username,
payto = it.getBankPayto("internal_payto", "name", db.ctx),
bankAccountId = it.getLong("bank_account_id"),
- isTalerExchange = it.getBoolean("is_taler_exchange")
+ isTalerExchange = it.getBoolean("is_taler_exchange"),
+ channels = it.getEnumSet<TanChannel>("tan_channels"),
+ phone = it.getString("phone"),
+ email = it.getString("email")
)
}
}
@@ -552,7 +551,7 @@ class AccountDAO(private val db: Database) {
customers.name
,email
,phone
- ,tan_channel
+ ,tan_channels
,cashout_payto
,internal_payto
,(balance).val AS balance_val
@@ -590,13 +589,15 @@ class AccountDAO(private val db: Database) {
val name = it.getString("name")
val status: AccountStatus = it.getEnum("status")
val isTalerExchange = it.getBoolean("is_taler_exchange")
+ val channels: Set<TanChannel> = it.getEnumSet("tan_channels")
AccountData(
name = name,
contact_data = ChallengeContactData(
email = Option.Some(it.getString("email")),
phone = Option.Some(it.getString("phone"))
),
- tan_channel = it.getOptEnum<TanChannel>("tan_channel"),
+ tan_channel = channels.firstOrNull(),
+ tan_channels = channels,
cashout_payto_uri = it.getOptIbanPayto("cashout_payto")?.full(name),
payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
balance = Balance(
@@ -742,4 +743,29 @@ class AccountDAO(private val db: Database) {
conversion_rate_class_id = it.getOptLong("conversion_rate_class_id")
)
}
-}
-\ No newline at end of file
+}
+
+/** List all new tan channels that need to be validated */
+fun AccountReconfiguration.requiredValidation(current: TanInfo): Tans {
+ // Tan channels are either the new ones or the current one
+ val channels = this.channels.get() ?: current.channels
+
+ val validated = current.mfa
+
+ return channels.mapNotNull { channel ->
+ // Info are either the new one or the current ones
+ val info = when (channel) {
+ TanChannel.sms -> this.contact_data?.phone?.get() ?: current.phone
+ TanChannel.email -> this.contact_data?.email?.get() ?: current.email
+ }
+ if (info == null) {
+ throw conflict(
+ "missing info for tan channel $channel",
+ TalerErrorCode.BANK_MISSING_TAN_INFO
+ )
+ }
+ val tan = Pair(channel, info)
+ // Check if tan is already used and therefore already validated
+ if (validated.contains(tan)) null else tan
+ }
+}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -96,11 +96,6 @@ class CashoutDAO(private val db: Database) {
,cashout_operations.subject
,creation_time
,transaction_date as confirmation_date
- ,tan_channel
- ,CASE tan_channel
- WHEN 'sms' THEN phone
- WHEN 'email' THEN email
- END as tan_info
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -112,7 +107,6 @@ class CashoutDAO(private val db: Database) {
bind(username)
oneOrNull {
CashoutStatusResponse(
- status = CashoutStatus.confirmed,
amount_debit = it.getAmount("amount_debit", db.bankCurrency),
amount_credit = it.getAmount("amount_credit", db.fiatCurrency!!),
subject = it.getString("subject"),
@@ -121,8 +115,6 @@ class CashoutDAO(private val db: Database) {
0L -> null
else -> TalerTimestamp(timestamp.asInstant())
},
- tan_channel = it.getOptEnum<TanChannel>("tan_channel"),
- tan_info = it.getString("tan_info"),
)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
@@ -22,78 +22,102 @@ package tech.libeufin.bank.db
import tech.libeufin.bank.Operation
import tech.libeufin.bank.TanChannel
import tech.libeufin.common.db.*
-import tech.libeufin.common.internalServerError
-import tech.libeufin.common.micros
+import tech.libeufin.common.*
+import tech.libeufin.bank.*
+import tech.libeufin.bank.auth.*
import java.time.Duration
import java.time.Instant
+import java.util.UUID
import java.util.concurrent.TimeUnit
/** Data access logic for tan challenged */
class TanDAO(private val db: Database) {
- /** Create new TAN challenge */
+ /** Create a new challenge */
suspend fun new(
username: String,
op: Operation,
- body: String,
+ hbody: Base32Crockford64B,
+ salt: Base32Crockford16B,
code: String,
timestamp: Instant,
retryCounter: Int,
validityPeriod: Duration,
- channel: TanChannel? = null,
- info: String? = null
- ): Long = db.serializable(
- "SELECT tan_challenge_create(?,?::op_enum,?,?,?,?,?,?::tan_enum,?)"
+ tanChannel: TanChannel,
+ tanInfo: String
+ ): UUID = db.serializable(
+ """
+ INSERT INTO tan_challenges (
+ hbody,
+ salt,
+ op,
+ code,
+ creation_date,
+ expiration_date,
+ retry_counter,
+ customer,
+ tan_channel,
+ tan_info,
+ uuid
+ ) VALUES (
+ ?,
+ ?,
+ ?::op_enum,
+ ?,
+ ?,
+ ?,
+ ?,
+ (SELECT customer_id FROM customers WHERE username = ? AND deleted_at IS NULL),
+ ?::tan_enum,
+ ?,
+ gen_random_uuid()
+ ) RETURNING uuid
+ """
) {
- bind(body)
+ bind(hbody)
+ bind(salt)
bind(op)
bind(code)
bind(timestamp)
- bind(TimeUnit.MICROSECONDS.convert(validityPeriod))
+ bind(timestamp.micros() + TimeUnit.MICROSECONDS.convert(validityPeriod))
bind(retryCounter)
bind(username)
- bind(channel)
- bind(info)
- oneOrNull {
- it.getLong(1)
- } ?: throw internalServerError("TAN challenge returned nothing.")
+ bind(tanChannel)
+ bind(tanInfo)
+ one {
+ it.getObject(1) as UUID
+ }
}
/** Result of TAN challenge transmission */
sealed interface TanSendResult {
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 */
suspend fun send(
- id: Long,
- username: String,
+ uuid: UUID,
code: String,
timestamp: Instant,
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, out_too_many
- FROM tan_challenge_send(?,?,?,?,?,?,?,?)
+ SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info, out_too_many
+ FROM tan_challenge_send(?,?,?,?,?,?)
"""
) {
- bind(id)
- bind(username)
+ bind(uuid)
bind(code)
bind(timestamp)
bind(TimeUnit.MICROSECONDS.convert(validityPeriod))
bind(retryCounter)
- bind(isAuth)
bind(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"),
@@ -106,13 +130,13 @@ class TanDAO(private val db: Database) {
/** Mark TAN challenge transmission */
suspend fun markSent(
- id: Long,
+ uuid: UUID,
timestamp: Instant,
retransmissionPeriod: Duration
) = db.serializable(
"SELECT tan_challenge_mark_sent(?,?,?)"
) {
- bind(id)
+ bind(uuid)
bind(timestamp)
bind(TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
executeQuery()
@@ -120,44 +144,36 @@ class TanDAO(private val db: Database) {
/** Result of TAN challenge solution */
sealed interface TanSolveResult {
- data class Success(val body: String, val op: Operation, val channel: TanChannel?, val info: String?): TanSolveResult
+ data class Success(val op: Operation, val channel: TanChannel?, val info: String?): TanSolveResult
data object NotFound: TanSolveResult
data object NoRetry: TanSolveResult
data object Expired: TanSolveResult
data object BadCode: TanSolveResult
- data object AuthRequired: TanSolveResult
}
/** Solve TAN challenge */
suspend fun solve(
- id: Long,
- username: String,
+ uuid: UUID,
code: String,
- timestamp: Instant,
- isAuth: Boolean,
+ timestamp: Instant
) = db.serializable(
"""
SELECT
out_ok, out_no_op, out_no_retry, out_expired,
- out_body, out_op, out_channel, out_info,
- out_auth_required
- FROM tan_challenge_try(?,?,?,?,?)
+ out_op, out_channel, out_info
+ FROM tan_challenge_try(?,?,?)
"""
) {
- bind(id)
- bind(username)
+ bind(uuid)
bind(code)
bind(timestamp.micros())
- bind(isAuth)
one {
when {
it.getBoolean("out_ok") -> TanSolveResult.Success(
- body = it.getString("out_body"),
op = it.getEnum("out_op"),
channel = it.getOptEnum<TanChannel>("out_channel"),
info = it.getString("out_info")
)
- it.getBoolean("out_auth_required") -> TanSolveResult.AuthRequired
it.getBoolean("out_no_op") -> TanSolveResult.NotFound
it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry
it.getBoolean("out_expired") -> TanSolveResult.Expired
@@ -166,33 +182,32 @@ class TanDAO(private val db: Database) {
}
}
- data class Challenge (
- val body: String,
- val channel: TanChannel?,
- val info: String?
+ data class SolvedChallenge(
+ val salt: Base32Crockford16B,
+ val hbody: Base32Crockford64B,
+ val channel: TanChannel,
+ val info: String,
+ val confirmed: Boolean,
+ val op: Operation
)
- /** Get a solved TAN challenge [id] for account [username] and [op] */
- suspend fun challenge(
- id: Long,
- username: String,
- op: Operation
- ) = db.serializable(
+ /** Get a TAN challenge [uuid] */
+ suspend fun challenge(uuids: List<UUID>): List<SolvedChallenge> = db.serializable(
"""
- SELECT body, tan_challenges.tan_channel, tan_info
+ SELECT salt, hbody, tan_channel, tan_info, op, (confirmation_date IS NOT NULL) as confirmed
FROM tan_challenges
- JOIN customers ON customer=customer_id
- WHERE challenge_id=? AND op=?::op_enum AND username=? AND deleted_at IS NULL
+ WHERE uuid = ANY(?::uuid[])
"""
) {
- bind(id)
- bind(op)
- bind(username)
- oneOrNull {
- Challenge(
- body = it.getString("body"),
- channel = it.getOptEnum<TanChannel>("tan_channel"),
- info = it.getString("tan_info")
+ bind(uuids.toTypedArray())
+ all {
+ SolvedChallenge(
+ salt = Base32Crockford16B(it.getBytes("salt")),
+ hbody = Base32Crockford64B(it.getBytes("hbody")),
+ op = it.getEnum<Operation>("op"),
+ channel = it.getEnum<TanChannel>("tan_channel"),
+ info = it.getString("tan_info"),
+ confirmed = it.getBoolean("confirmed")
)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
@@ -110,7 +110,10 @@ class TokenDAO(private val db: Database) {
is_taler_exchange,
bank_account_id,
internal_payto,
- name
+ name,
+ tan_channels,
+ email,
+ phone
"""
) {
bind(accessTime)
@@ -127,7 +130,10 @@ class TokenDAO(private val db: Database) {
username = it.getString("username"),
payto = it.getBankPayto("internal_payto", "name", db.ctx),
bankAccountId = it.getLong("bank_account_id"),
- isTalerExchange = it.getBoolean("is_taler_exchange")
+ isTalerExchange = it.getBoolean("is_taler_exchange"),
+ channels = it.getEnumSet<TanChannel>("tan_channels"),
+ phone = it.getString("phone"),
+ email = it.getString("email")
)
)
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -116,9 +116,9 @@ suspend fun createAdminAccount(db: Database, cfg: BankConfig, pw: String? = null
email = null,
phone = null,
cashoutPayto = null,
- tanChannel = null,
+ tanChannels = emptySet(),
pwCrypto = cfg.pwCrypto,
- conversionRateClassId = null
+ conversionRateClassId = null,
)
}
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -238,16 +238,17 @@ class CoreBankTokenApiTest {
// Check creating a token requires to solve an unauthenticated challenge
val challenge = client.postPw("/accounts/merchant/token") {
json { "scope" to "readonly" }
- }.assertAcceptedJson<TanChallenge>()
- val transmission = client.post("/accounts/merchant/challenge/${challenge.challenge_id}")
- .assertOkJson<TanTransmission>()
- assertEquals("REDACTED", transmission.tan_info) // Check phone number is hidden
+ }.assertAcceptedJson<ChallengeResponse>().challenges[0]
+ client.post("/accounts/merchant/challenge/${challenge.challenge_id}")
+ .assertNoContent()
+ assertEquals("REDACTED", challenge.tan_info) // Check phone number is hidden
val code = tanCode("+12345")
client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") {
json { "tan" to code }
}.assertNoContent()
client.postPw("/accounts/merchant/token") {
- headers[X_CHALLENGE_ID] = "${challenge.challenge_id}"
+ headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}"
+ json { "scope" to "readonly" }
}.assertOkJson<TokenSuccessResponse>()
}
@@ -268,12 +269,12 @@ class CoreBankTokenApiTest {
while (counter > 0) {
val challenge = client.postPw("/accounts/merchant/token") {
json { "scope" to "readonly" }
- }.assertAcceptedJson<TanChallenge>()
- val transmission = client.post("/accounts/merchant/challenge/${challenge.challenge_id}")
- .assertOkJson<TanTransmission>()
+ }.assertAcceptedJson<ChallengeResponse>().challenges[0]
+ client.post("/accounts/merchant/challenge/${challenge.challenge_id}")
+ .assertNoContent()
while (counter > 0) {
- val error = client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") {
- json { "tan" to "bad code" }
+ val error = client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm"){
+ json { "tan" to "bad code" }
}.json<TalerError>()
counter -= 1
when (error.code) {
@@ -490,8 +491,20 @@ class CoreBankAccountsApiTest {
}.assertOk()
}
+ // Check both tan channels
+ client.postAdmin("/accounts") {
+ json {
+ "username" to "bat2"
+ "password" to "password"
+ "name" to "Bat"
+ "tan_channel" to "sms"
+ "tan_channels" to emptyList<String>()
+ }
+ }.assertBadRequest()
+
// Check tan info
- for (channel in listOf("sms", "email")) {
+ val channels = listOf("sms", "email")
+ for (channel in channels) {
client.postAdmin("/accounts") {
json {
"username" to "bat2"
@@ -500,7 +513,23 @@ class CoreBankAccountsApiTest {
"tan_channel" to channel
}
}.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
+ client.postAdmin("/accounts") {
+ json {
+ "username" to "bat2"
+ "password" to "password"
+ "name" to "Bat"
+ "tan_channels" to listOf(channel)
+ }
+ }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
}
+ client.postAdmin("/accounts") {
+ json {
+ "username" to "bat2"
+ "password" to "password"
+ "name" to "Bat"
+ "tan_channels" to channels
+ }
+ }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
// Check unknown conversion rate class
client.postAdmin("/accounts") {
@@ -776,7 +805,7 @@ class CoreBankAccountsApiTest {
"payto_uri" to "$exchangePayto?message=payout"
"amount" to "KUDOS:0.3"
}
- }.assertAcceptedJson<TanChallenge>().challenge_id
+ }.assertAcceptedJson<ChallengeResponse>()
// Delete account
tx("merchant", "KUDOS:1", "customer")
@@ -854,11 +883,18 @@ class CoreBankAccountsApiTest {
authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true)
// Check tan info
- for (channel in listOf("sms", "email")) {
+ val channels = listOf("sms", "email")
+ for (channel in channels) {
client.patchA("/accounts/merchant") {
json { "tan_channel" to channel }
}.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
+ client.patchA("/accounts/merchant") {
+ json { "tan_channels" to listOf(channel) }
+ }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
}
+ client.patchA("/accounts/merchant") {
+ json { "tan_channels" to channels }
+ }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
// Successful attempt now
val cashout = IbanPayto.rand()
@@ -968,7 +1004,7 @@ class CoreBankAccountsApiTest {
fillTanInfo("merchant")
client.patchA("/accounts/merchant") {
json { "is_public" to false }
- }.assertChallenge { _, _ ->
+ }.assertChallenge {
client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
assert(obj.is_public)
}
@@ -1430,7 +1466,7 @@ class CoreBankTransactionsApiTest {
json {
"payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1"
}
- }.assertChallenge { _,_->
+ }.assertChallenge {
assertBalance("merchant", "+KUDOS:3")
assertBalance("customer", "+KUDOS:0")
}.assertOkJson <TransactionCreateResponse> {
@@ -1445,7 +1481,7 @@ class CoreBankTransactionsApiTest {
}
val id = client.postA("/accounts/merchant/transactions") {
json(req)
- }.assertChallenge { _,_->
+ }.assertChallenge {
assertBalance("merchant", "+KUDOS:2")
assertBalance("customer", "+KUDOS:1")
}.assertOkJson <TransactionCreateResponse> {
@@ -1795,7 +1831,7 @@ class CoreBankWithdrawalApiTest {
withdrawalSelect(uuid)
client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
- .assertChallenge { _,_->
+ .assertChallenge {
assertBalance("merchant", "-KUDOS:7")
}.assertNoContent()
}
@@ -1812,7 +1848,7 @@ class CoreBankWithdrawalApiTest {
client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
json { "amount" to "KUDOS:1" }
}
- .assertChallenge { _,_->
+ .assertChallenge {
assertBalance("merchant", "-KUDOS:8")
}.assertNoContent()
}
@@ -1952,7 +1988,7 @@ class CoreBankCashoutApiTest {
json(req) {
"request_uid" to ShortHashCode.rand()
}
- }.assertChallenge { _,_->
+ }.assertChallenge {
assertBalance("customer", "-KUDOS:1")
}.assertOkJson<CashoutResponse> {
assertBalance("customer", "-KUDOS:2")
@@ -1979,11 +2015,8 @@ class CoreBankCashoutApiTest {
val id = it.cashout_id
client.getA("/accounts/customer/cashouts/$id")
.assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.confirmed, it.status)
assertEquals(amountDebit, it.amount_debit)
assertEquals(amountCredit, it.amount_credit)
- assertNull(it.tan_channel)
- assertNull(it.tan_info)
}
}
@@ -2043,19 +2076,16 @@ class CoreBankTanApiTest {
// POST /accounts/{USERNAME}/challenge/{challenge_id}
@Test
fun send() = bankSetup {
- authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42")
-
- suspend fun HttpResponse.expectChallenge(channel: TanChannel, info: String): HttpResponse {
- return assertChallenge { tanChannel, tanInfo ->
- assertEquals(channel, tanChannel)
- assertEquals(info, tanInfo)
+ suspend fun HttpResponse.expectMfa(vararg tans: Pair<TanChannel, String>): HttpResponse {
+ return assertChallenge { res ->
+ assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet())
+ assertFalse(res.combi_and)
}
}
-
- suspend fun HttpResponse.expectTransmission(channel: TanChannel, info: String) {
- this.assertOkJson<TanTransmission> {
- assertEquals(it.tan_channel, channel)
- assertEquals(it.tan_info, info)
+ suspend fun HttpResponse.expectValidation(vararg tans: Pair<TanChannel, String>): HttpResponse {
+ return assertChallenge { res ->
+ assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet())
+ assertTrue(res.combi_and)
}
}
@@ -2068,7 +2098,7 @@ class CoreBankTanApiTest {
}
"tan_channel" to "sms"
}
- }.expectChallenge(TanChannel.sms, "+99")
+ }.expectValidation(TanChannel.sms to "+99")
.assertNoContent()
// Update 2fa settings - first 2FA challenge then new tan channel check
@@ -2076,31 +2106,56 @@ class CoreBankTanApiTest {
json { // Info change
"contact_data" to obj { "phone" to "+98" }
}
- }.expectChallenge(TanChannel.sms, "+99")
- .expectChallenge(TanChannel.sms, "+98")
+ }.expectMfa(TanChannel.sms to "+99")
+ .expectValidation(TanChannel.sms to "+98")
.assertNoContent()
client.patchA("/accounts/merchant") {
json { // Channel change
"tan_channel" to "email"
}
- }.expectChallenge(TanChannel.sms, "+98")
- .expectChallenge(TanChannel.email, "email@example.com")
+ }.expectMfa(TanChannel.sms to "+98")
+ .expectValidation(TanChannel.email to "email@example.com")
.assertNoContent()
client.patchA("/accounts/merchant") {
json { // Both change
"contact_data" to obj { "phone" to "+97" }
"tan_channel" to "sms"
}
- }.expectChallenge(TanChannel.email, "email@example.com")
- .expectChallenge(TanChannel.sms, "+97")
+ }.expectMfa(TanChannel.email to "email@example.com")
+ .expectValidation(TanChannel.sms to "+97")
.assertNoContent()
// Disable 2fa
client.patchA("/accounts/merchant") {
json { "tan_channel" to null as String? }
- }.expectChallenge(TanChannel.sms, "+97")
+ }.expectMfa(TanChannel.sms to "+97")
+ .assertNoContent()
+
+ // Update mfa settings - first mfa challenge then new tan channel check
+ client.patchA("/accounts/merchant") {
+ json { // All channels
+ "tan_channels" to setOf("sms", "email")
+ }
+ }.expectValidation(TanChannel.sms to "+97", TanChannel.email to "email@example.com")
+ .assertNoContent()
+ client.patchA("/accounts/merchant") {
+ json { // All info changes
+ "contact_data" to obj {
+ "phone" to "+99"
+ "email" to "email2@example.com"
+ }
+ }
+ }.expectMfa(TanChannel.sms to "+97", TanChannel.email to "email@example.com")
+ .expectValidation(TanChannel.sms to "+99", TanChannel.email to "email2@example.com")
.assertNoContent()
+ // Disable mfa
+ client.patchA("/accounts/merchant") {
+ json { "tan_channels" to emptySet<String>() }
+ }.expectMfa(TanChannel.sms to "+99", TanChannel.email to "email2@example.com")
+ .assertNoContent()
+
+
// Admin has no 2FA
client.patchAdmin("/accounts/merchant") {
json {
@@ -2124,14 +2179,16 @@ class CoreBankTanApiTest {
}.assertChallenge().assertNoContent()
client.patchA("/accounts/merchant") {
json { "is_public" to false }
- }.assertAcceptedJson<TanChallenge> {
+ }.assertAcceptedJson<ChallengeResponse> {
+ val challenge = it.challenges[0]
// Check ok
- client.postA("/accounts/merchant/challenge/${it.challenge_id}")
- .expectTransmission(TanChannel.sms, "+88")
- assertNotNull(tanCode("+88"))
+ client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
+ .assertNoContent()
+ val code = tanCode("+88")
+ assertNotNull(code)
// Check retry
- client.postA("/accounts/merchant/challenge/${it.challenge_id}")
- .expectTransmission(TanChannel.sms, "+88")
+ client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
+ .assertNoContent()
assertNull(tanCode("+88"))
// Idempotent patch does nothing
client.patchA("/accounts/merchant") {
@@ -2140,25 +2197,32 @@ class CoreBankTanApiTest {
"tan_channel" to "sms"
}
}
- client.postA("/accounts/merchant/challenge/${it.challenge_id}")
- .expectTransmission(TanChannel.sms, "+88")
+ client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
+ .assertNoContent()
assertNull(tanCode("+88"))
+
// Change 2fa settings
client.patchA("/accounts/merchant") {
json {
"tan_channel" to "email"
}
- }.expectChallenge(TanChannel.sms, "+88")
- .expectChallenge(TanChannel.email, "email@example.com")
+ }.expectMfa(TanChannel.sms to "+88")
+ .expectValidation(TanChannel.email to "email2@example.com")
.assertNoContent()
+
// Check invalidated
- client.postA("/accounts/merchant/challenge/${it.challenge_id}")
- .expectTransmission(TanChannel.email, "email@example.com")
- assertNotNull(tanCode("email@example.com"))
+ client.postA("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") {
+ json { "tan" to code }
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)
+ client.patchA("/accounts/merchant") {
+ headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}"
+ json { "is_public" to false }
+ }.expectMfa(TanChannel.email to "email2@example.com")
+ .assertNoContent()
}
// Unknown challenge
- client.postA("/accounts/merchant/challenge/42")
+ client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}")
.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}
@@ -2171,15 +2235,16 @@ class CoreBankTanApiTest {
json {
"payto_uri" to "$customerPayto?message=tx&amount=KUDOS:0.1"
}
- }.assertAcceptedJson<TanChallenge>()
- suspend fun ApplicationTestBuilder.submit(challenge: TanChallenge)
+ }.assertAcceptedJson<ChallengeResponse>().challenges[0]
+ suspend fun ApplicationTestBuilder.submit(challenge: Challenge)
= client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
- .assertOkJson<TanTransmission>()
+ .assertNoContent()
// Start a legitimate challenge and submit it
val oldChallenge = txChallenge()
- val tanCode = tanCode(submit(oldChallenge).tan_info)
+ submit(oldChallenge)
+ val tanCode = tanCode(oldChallenge.tan_info)
// Challenge creation is not rate limited
repeat(MAX_ACTIVE_CHALLENGES*2) {
@@ -2216,8 +2281,9 @@ class CoreBankTanApiTest {
fillTanInfo("merchant")
client.patchA("/accounts/merchant") {
json { "is_public" to false }
- }.assertAcceptedJson<TanChallenge> {
- client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+ }.assertAcceptedJson<ChallengeResponse> {
+ val challenge = it.challenges[0]
+ client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
.assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)
}
}
@@ -2225,18 +2291,17 @@ class CoreBankTanApiTest {
// POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm
@Test
fun confirm() = bankSetup {
- authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm", obj { "tan" to "code" })
-
fillTanInfo("merchant")
// Check simple case
client.patchA("/accounts/merchant") {
json { "is_public" to false }
- }.assertAcceptedJson<TanChallenge> {
- val id = it.challenge_id
- val info = client.postA("/accounts/merchant/challenge/$id")
- .assertOkJson<TanTransmission>().tan_info
- val code = tanCode(info)
+ }.assertAcceptedJson<ChallengeResponse> {
+ val challenge = it.challenges[0]
+ val id = challenge.challenge_id
+ client.postA("/accounts/merchant/challenge/$id")
+ .assertNoContent()
+ val code = tanCode(challenge.tan_info)
// Check bad TAN code
client.postA("/accounts/merchant/challenge/$id/confirm") {
@@ -2246,7 +2311,7 @@ class CoreBankTanApiTest {
// Check wrong account
client.postA("/accounts/customer/challenge/$id/confirm") {
json { "tan" to "nice-try" }
- }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
// Check OK
client.postA("/accounts/merchant/challenge/$id/confirm") {
@@ -2258,7 +2323,7 @@ class CoreBankTanApiTest {
}.assertNoContent()
// Unknown challenge
- client.postA("/accounts/merchant/challenge/42/confirm") {
+ client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}/confirm") {
json { "tan" to code }
}.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
}
@@ -2266,20 +2331,21 @@ class CoreBankTanApiTest {
// Check invalidation
client.patchA("/accounts/merchant") {
json { "is_public" to true }
- }.assertAcceptedJson<TanChallenge> {
- val id = it.challenge_id
- val info = client.postA("/accounts/merchant/challenge/$id")
- .assertOkJson<TanTransmission>().tan_info
+ }.assertAcceptedJson<ChallengeResponse> {
+ val challenge = it.challenges[0]
+ val id = challenge.challenge_id
+ client.postA("/accounts/merchant/challenge/$id")
+ .assertNoContent()
- // Check invalidated
- fillTanInfo("merchant")
+ // Check invalidated TODO
+ /*fillTanInfo("merchant")
client.postA("/accounts/merchant/challenge/$id/confirm") {
json { "tan" to tanCode(info) }
- }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)*/
- val new = client.postA("/accounts/merchant/challenge/$id")
- .assertOkJson<TanTransmission>().tan_info
- val code = tanCode(new)
+ client.postA("/accounts/merchant/challenge/$id")
+ .assertNoContent()
+ val code = tanCode(challenge.tan_info)
// Idempotent patch does nothing
client.patchA("/accounts/merchant") {
json {
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
@@ -20,10 +20,10 @@
import io.ktor.http.*
import kotlinx.coroutines.*
import org.junit.Test
-import tech.libeufin.bank.createAdminAccount
+import tech.libeufin.bank.*
import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
-import tech.libeufin.common.TalerError
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.bank.db.TanDAO.*
+import tech.libeufin.common.*
import tech.libeufin.common.assertOk
import tech.libeufin.common.db.*
import tech.libeufin.common.json
@@ -31,6 +31,7 @@ import tech.libeufin.common.test.*
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
+import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertIs
@@ -49,48 +50,38 @@ class DatabaseTest {
@Test
fun tanChallenge() = bankSetup { db -> db.conn { conn ->
- val createStmt = conn.talerStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)")
- val markSentStmt = conn.talerStatement("SELECT tan_challenge_mark_sent(?,?,?)")
- val tryStmt = conn.talerStatement("SELECT out_ok, out_no_retry, out_expired FROM tan_challenge_try(?,'customer',?,?,true)")
- val sendStmt = conn.talerStatement("SELECT out_tan_code FROM tan_challenge_send(?,'customer',?,?,?,?,true,10)")
-
val validityPeriod = Duration.ofHours(1)
val retransmissionPeriod: Duration = Duration.ofMinutes(1)
val retryCounter = 3
- fun create(code: String, timestamp: Instant): Long {
- createStmt.bind(code)
- createStmt.bind(ChronoUnit.MICROS.between(Instant.EPOCH, timestamp))
- createStmt.bind(TimeUnit.MICROSECONDS.convert(validityPeriod))
- createStmt.bind(retryCounter)
- return createStmt.one { it.getLong(1) }
+ suspend fun create(code: String, timestamp: Instant): UUID {
+ return db.tan.new(
+ hbody = Base32Crockford64B.rand(),
+ salt = Base32Crockford16B.rand(),
+ username = "customer",
+ op = Operation.withdrawal,
+ code = code,
+ timestamp = timestamp,
+ retryCounter = retryCounter,
+ validityPeriod = validityPeriod,
+ tanChannel = TanChannel.sms,
+ tanInfo = "+88"
+ )
}
- fun markSent(id: Long, timestamp: Instant) {
- markSentStmt.bind(id)
- markSentStmt.bind(ChronoUnit.MICROS.between(Instant.EPOCH, timestamp))
- markSentStmt.bind(TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
- return markSentStmt.one { }
+ suspend fun markSent(id: UUID, timestamp: Instant) {
+ db.tan.markSent(id, timestamp, retransmissionPeriod)
}
- fun cTry(id: Long, code: String, timestamp: Instant): Triple<Boolean, Boolean, Boolean> {
- tryStmt.bind(id)
- tryStmt.bind(code)
- tryStmt.bind(ChronoUnit.MICROS.between(Instant.EPOCH, timestamp))
- return tryStmt.one {
- Triple(it.getBoolean(1), it.getBoolean(2), it.getBoolean(3))
- }
- }
-
- fun send(id: Long, code: String, timestamp: Instant): String? {
- sendStmt.bind(id)
- sendStmt.bind(code)
- sendStmt.bind(ChronoUnit.MICROS.between(Instant.EPOCH, timestamp))
- sendStmt.bind(TimeUnit.MICROSECONDS.convert(validityPeriod))
- sendStmt.bind(retryCounter)
- return sendStmt.oneOrNull {
- it.getString(1)
- }
+ suspend fun send(id: UUID, code: String, timestamp: Instant): String? {
+ return (db.tan.send(
+ id,
+ code,
+ timestamp,
+ retryCounter,
+ validityPeriod,
+ 10
+ ) as TanSendResult.Success).tanCode
}
val now = Instant.now()
@@ -100,13 +91,13 @@ class DatabaseTest {
// Check basic
create("good-code", now).run {
// Bad code
- assertEquals(Triple(false, false, false), cTry(this, "bad-code", now))
+ assertEquals(TanSolveResult.BadCode, db.tan.solve(this, "bad-code", now))
// Good code
- assertEquals(Triple(true, false, false), cTry(this, "good-code", now))
+ assertIs<TanSolveResult.Success>(db.tan.solve(this, "good-code", now))
// Never resend a confirmed challenge
assertNull(send(this, "new-code", expired))
// Confirmed challenge always ok
- assertEquals(Triple(true, false, false), cTry(this, "good-code", now))
+ assertIs<TanSolveResult.Success>(db.tan.solve(this, "good-code", now))
}
// Check retry
@@ -114,15 +105,15 @@ class DatabaseTest {
markSent(this, now)
// Bad code
repeat(retryCounter-1) {
- assertEquals(Triple(false, false, false), cTry(this, "bad-code", now))
+ assertEquals(TanSolveResult.BadCode, db.tan.solve(this, "bad-code", now))
}
- assertEquals(Triple(false, true, false), cTry(this, "bad-code", now))
+ assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "bad-code", now))
// Good code fail
- assertEquals(Triple(false, true, false), cTry(this, "good-code", now))
+ assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "good-code", now))
// New code
assertEquals("new-code", send(this, "new-code", now))
// Good code
- assertEquals(Triple(true, false, false), cTry(this, "new-code", now))
+ assertIs<TanSolveResult.Success>(db.tan.solve(this, "new-code", now))
}
// Check retransmission and expiration
@@ -135,16 +126,16 @@ class DatabaseTest {
// Code is still valid but should be resent
assertEquals("good-code", send(this, "new-code", retransmit))
// Good code fail because expired
- assertEquals(Triple(false, false, true), cTry(this, "good-code", 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(Triple(false, false, false), cTry(this, "good-code", expired))
+ assertEquals(TanSolveResult.BadCode, db.tan.solve(this, "good-code", expired))
// New code works
- assertEquals(Triple(true, false, false), cTry(this, "new-code", expired))
+ assertIs<TanSolveResult.Success>(db.tan.solve(this, "new-code", expired))
}
}}
}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt
@@ -19,9 +19,7 @@
import io.ktor.client.request.*
import org.junit.Test
-import tech.libeufin.bank.Operation
-import tech.libeufin.bank.RegisterAccountResponse
-import tech.libeufin.bank.TokenScope
+import tech.libeufin.bank.*
import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult
import tech.libeufin.bank.db.ExchangeDAO.TransferResult
import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
@@ -88,7 +86,7 @@ class GcTest {
for (time in listOf(now, clean)) {
for (account in listOf("old_account", "recent_account")) {
db.token.create(account, ByteArray(32).rand(), time, time, TokenScope.readonly, false, null, true)
- db.tan.new(account, Operation.cashout, "", "", time, 0, Duration.ZERO, null, null)
+ db.tan.new(account, Operation.cashout, Base32Crockford64B.rand(), Base32Crockford16B.rand(), "", time, 0, Duration.ZERO, TanChannel.sms, "")
}
}
assertNbTokens(5)
diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt
@@ -47,6 +47,7 @@ class Bench {
val password = PwCrypto.Bcrypt(cost = 4).hashpw("password")
+ val token16 = ByteArray(16)
val token32 = ByteArray(32)
val token64 = ByteArray(64)
@@ -80,9 +81,12 @@ class Bench {
val hex = token32.rand().encodeHex()
"\\\\x$hex\t$it\n"
},
- "tan_challenges(body, op, code, creation_date, expiration_date, retry_counter, customer)" to {
+ "tan_challenges(uuid, hbody, salt, op, code, creation_date, expiration_date, retry_counter, customer, tan_channel, tan_info)" to {
val account = if (it > mid) customerAccount else it+4
- "body\taccount_reconfig\tcode\t0\t0\t0\t$account\n"
+ val uuid = UUID.randomUUID()
+ val hex16 = token16.rand().encodeHex()
+ val hex64 = token64.rand().encodeHex()
+ "$uuid\t\\\\x$hex64\t\\\\x$hex16\taccount_reconfig\tcode\t0\t0\t0\t$account\tsms\tinfo\n"
},
"taler_withdrawal_operations(withdrawal_uuid, wallet_bank_account, reserve_pub, creation_date)" to {
val account = if (it > mid) customerAccount else it+4
@@ -343,7 +347,7 @@ class Bench {
// TAN challenges
val challenges = measureAction("tan_send") {
- val id = client.patchA("/accounts/account_bench_$it") {
+ val res = client.patchA("/accounts/account_bench_$it") {
json {
"contact_data" to obj {
"phone" to "+99"
@@ -351,10 +355,11 @@ class Bench {
}
"tan_channel" to "sms"
}
- }.assertAcceptedJson<TanChallenge>().challenge_id
- val res = client.postA("/accounts/account_bench_$it/challenge/$id").assertOkJson<TanTransmission>()
- val code = tanCode(res.tan_info)
- Pair(id, code)
+ }.assertAcceptedJson<ChallengeResponse>()
+ val challenge = res.challenges[0]
+ client.postA("/accounts/account_bench_$it/challenge/${challenge.challenge_id}").assertNoContent()
+ val code = tanCode(challenge.tan_info)
+ Pair(challenge.challenge_id, code)
}
measureAction("tan_confirm") {
val (id, code) = challenges[it]
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -96,7 +96,7 @@ fun bankSetup(
email = null,
phone = null,
cashoutPayto = null,
- tanChannel = null,
+ tanChannels = emptySet(),
conversionRateClassId = null,
pwCrypto = cfg.pwCrypto
))
@@ -113,7 +113,7 @@ fun bankSetup(
email = null,
phone = null,
cashoutPayto = null,
- tanChannel = null,
+ tanChannels = emptySet(),
conversionRateClassId = null,
pwCrypto = cfg.pwCrypto
))
@@ -130,7 +130,7 @@ fun bankSetup(
email = null,
phone = null,
cashoutPayto = null,
- tanChannel = null,
+ tanChannels = emptySet(),
conversionRateClassId = null,
pwCrypto = cfg.pwCrypto
))
@@ -351,21 +351,45 @@ suspend fun HttpResponse.maybeChallenge(): HttpResponse {
}
suspend fun HttpResponse.assertChallenge(
- check: suspend (TanChannel, String) -> Unit = { _, _ -> }
+ check: suspend (ChallengeResponse) -> Unit = {}
): HttpResponse {
- val id = assertAcceptedJson<TanChallenge>().challenge_id
+ val res = assertAcceptedJson<ChallengeResponse>()
val username = call.request.url.segments[1]
- val res = call.client.postA("/accounts/$username/challenge/$id").assertOkJson<TanTransmission>()
- check(res.tan_channel, res.tan_info)
- val code = tanCode(res.tan_info)
- assertNotNull(code)
- call.client.postA("/accounts/$username/challenge/$id/confirm") {
- json { "tan" to code }
- }.assertNoContent()
+
+ val challenge = res.challenges.random()
+
+ if (res.combi_and) {
+ for (challenge in res.challenges) {
+ call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertNoContent()
+ }
+ } else {
+ call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertNoContent()
+ }
+
+ check(res)
+
+ if (res.combi_and) {
+ for (challenge in res.challenges) {
+ val code = assertNotNull(tanCode(challenge.tan_info))
+ call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+ }
+ } else {
+ val code = assertNotNull(tanCode(challenge.tan_info))
+ call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+ }
+
+ // Recover body from request
+ val requestBody = this.request.content
+ val ids = res.challenges.map { it.challenge_id }.joinToString(",")
return call.client.request(this.call.request.url) {
tokenAuth(call.client, username)
method = call.request.method
- headers[X_CHALLENGE_ID] = "$id"
+ headers[TALER_CHALLENGE_IDS] = ids
+ setBody(requestBody)
}
}
diff --git a/common/src/main/kotlin/Constants.kt b/common/src/main/kotlin/Constants.kt
@@ -30,7 +30,8 @@ const val WIRE_GATEWAY_API_VERSION: String = "4:0:3"
const val REVENUE_API_VERSION: String = "1:1:1"
// HTTP headers
-const val X_CHALLENGE_ID: String = "X-Challenge-Id"
+const val TALER_CHALLENGE_IDS: String = "Taler-Challenge-Ids"
+const val X_FORWARD_PREFIX: String = "X-Forward-Prefix"
// Params
const val MAX_PAGE_SIZE: Int = 1024
diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt
@@ -540,6 +540,61 @@ data class BankPaytoCtx(
)
+/** 16-byte Crockford's Base32 encoded data */
+@Serializable(with = Base32Crockford16B.Serializer::class)
+class Base32Crockford16B {
+ private var encoded: String? = null
+ val raw: ByteArray
+
+ constructor(encoded: String) {
+ val decoded = try {
+ Base32Crockford.decode(encoded)
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+ require(decoded != null && decoded.size == 16) {
+ "expected 16 bytes encoded in Crockford's base32"
+ }
+ this.raw = decoded
+ this.encoded = encoded
+ }
+ constructor(raw: ByteArray) {
+ require(raw.size == 16) {
+ "encoded data should be 16 bytes long"
+ }
+ this.raw = raw
+ }
+
+ fun encoded(): String {
+ val tmp = encoded ?: Base32Crockford.encode(raw)
+ encoded = tmp
+ return tmp
+ }
+
+ override fun toString(): String {
+ return encoded()
+ }
+
+ override fun equals(other: Any?) = (other is Base32Crockford16B) && raw.contentEquals(other.raw)
+
+ internal object Serializer : KSerializer<Base32Crockford16B> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford16B", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: Base32Crockford16B) {
+ encoder.encodeString(value.encoded())
+ }
+
+ override fun deserialize(decoder: Decoder): Base32Crockford16B {
+ return Base32Crockford16B(decoder.decodeString())
+ }
+ }
+
+ companion object {
+ fun rand(): Base32Crockford16B = Base32Crockford16B(ByteArray(16).rand())
+ fun secureRand(): Base32Crockford16B = Base32Crockford16B(ByteArray(16).secureRand())
+ }
+}
+
/** 32-byte Crockford's Base32 encoded data */
@Serializable(with = Base32Crockford32B.Serializer::class)
class Base32Crockford32B {
diff --git a/common/src/main/kotlin/api/server.kt b/common/src/main/kotlin/api/server.kt
@@ -35,6 +35,7 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
+import io.ktor.util.*
import io.ktor.util.pipeline.*
import io.ktor.http.content.*
import kotlinx.serialization.ExperimentalSerializationApi
@@ -49,6 +50,12 @@ import java.sql.SQLException
import java.util.zip.DataFormatException
import java.util.zip.Inflater
+/** Used to store the raw body */
+private val RAW_BODY = AttributeKey<ByteArray>("RAW_BODY")
+
+/** Get call raw body */
+val ApplicationCall.rawBody: ByteArray get() = attributes.getOrNull(RAW_BODY) ?: ByteArray(0)
+
/**
* This plugin apply Taler specific logic
* It checks for body length limit and inflates the requests that have "Content-Encoding: deflate"
@@ -120,6 +127,7 @@ fun talerPlugin(logger: Logger): ApplicationPlugin<Unit> {
logger.trace {
"request ${bytes.sliceArray(0 until read).asUtf8()}"
}
+ call.attributes.put(RAW_BODY, bytes)
ByteReadChannel(bytes, 0, read)
}
}
diff --git a/common/src/main/kotlin/crypto/CryptoUtil.kt b/common/src/main/kotlin/crypto/CryptoUtil.kt
@@ -30,7 +30,7 @@ import org.bouncycastle.crypto.generators.BCrypt
import org.bouncycastle.crypto.params.Argon2Parameters
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
-import tech.libeufin.common.encodeHex
+import tech.libeufin.common.*
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.math.BigInteger
@@ -283,4 +283,22 @@ object CryptoUtil {
gen.generateBytes(password.toCharArray(), result, 0, result.size)
return result
}
+
+ private fun mfaBodyHash(body: ByteArray, salt: Base32Crockford16B): Base32Crockford64B {
+ val digest = MessageDigest.getInstance("SHA-512")
+ digest.update(salt.raw)
+ val hash = digest.digest(body)
+ return Base32Crockford64B(hash)
+ }
+
+ fun mfaBodyHashCheck(body: ByteArray, hash: Base32Crockford64B, salt: Base32Crockford16B): Boolean {
+ val check = mfaBodyHash(body, salt)
+ return check == hash
+ }
+
+ fun mfaBodyHashCreate(body: ByteArray): Pair<Base32Crockford64B, Base32Crockford16B> {
+ val salt = Base32Crockford16B.secureRand()
+ val hash = mfaBodyHash(body, salt)
+ return Pair(hash, salt)
+ }
}
\ No newline at end of file
diff --git a/common/src/main/kotlin/db/statement.kt b/common/src/main/kotlin/db/statement.kt
@@ -103,6 +103,12 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable {
idx+=1
}
+ fun bind(bytes: Base32Crockford16B?) {
+ stmt.setBytes(idx, bytes?.raw)
+ idx+=1
+ }
+
+
fun bind(bytes: ByteArray?) {
stmt.setBytes(idx, bytes)
idx+=1
@@ -122,6 +128,24 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable {
idx+=1
}
+ fun <T : Enum<T>> bind(array: Array<T>) {
+ val sqlArray = stmt.connection.createArrayOf("text", array)
+ stmt.setArray(idx, sqlArray)
+ idx+=1
+ }
+
+ fun bind(array: Array<String>) {
+ val sqlArray = stmt.connection.createArrayOf("text", array)
+ stmt.setArray(idx, sqlArray)
+ idx+=1
+ }
+
+ fun bind(array: Array<UUID>) {
+ val sqlArray = stmt.connection.createArrayOf("uuid", array)
+ stmt.setArray(idx, sqlArray)
+ idx+=1
+ }
+
/* ----- Transaction helpers ----- */
fun executeQuery(): ResultSet {
diff --git a/common/src/main/kotlin/db/types.kt b/common/src/main/kotlin/db/types.kt
@@ -48,6 +48,14 @@ inline fun <reified T : Enum<T>> ResultSet.getEnum(idx: Int): T
inline fun <reified T : Enum<T>> ResultSet.getOptEnum(name: String): T?
= getString(name)?.run { java.lang.Enum.valueOf(T::class.java, this) }
+inline fun <reified T : Enum<T>> ResultSet.getEnumSet(name: String): Set<T> {
+ val sqlArray: java.sql.Array = this.getArray(name)
+ val javaArray: Array<String> = (sqlArray.array as Array<String>)
+ val set: Set<T> = javaArray.map { java.lang.Enum.valueOf(T::class.java, it) }.toSet()
+ sqlArray.free()
+ return set
+}
+
fun ResultSet.getOptLong(name: String): Long? {
val nb = getLong(name)
if (wasNull()) return null
diff --git a/database-versioning/libeufin-bank-0011.sql b/database-versioning/libeufin-bank-0011.sql
@@ -18,7 +18,6 @@ BEGIN;
SELECT _v.register_patch('libeufin-bank-0011', NULL, NULL);
SET search_path TO libeufin_bank;
--- Add new token scope 'revenue'
ALTER TYPE op_enum ADD VALUE 'create_token';
ALTER TABLE customers ADD COLUMN token_creation_counter INT2 NOT NULL DEFAULT 0;
diff --git a/database-versioning/libeufin-bank-0014.sql b/database-versioning/libeufin-bank-0014.sql
@@ -24,4 +24,28 @@ ALTER TABLE cashout_operations DROP CONSTRAINT cashout_operations_pkey;
ALTER TABLE cashout_operations ADD CONSTRAINT request_uid_unique UNIQUE (request_uid);
ALTER TABLE cashout_operations ALTER COLUMN request_uid DROP NOT NULL;
+-- Allow user accounts to have many tan channels
+ALTER TABLE customers
+ ADD COLUMN tan_channels tan_enum[] NOT NULL DEFAULT ARRAY[]::tan_enum[];
+UPDATE customers
+ SET tan_channels = ARRAY[tan_channel]
+ WHERE tan_channel IS NOT NULL;
+ALTER TABLE customers DROP COLUMN tan_channel;
+
+-- Only store salted body hash in challenges
+TRUNCATE TABLE tan_challenges;
+ALTER TABLE tan_challenges
+ DROP COLUMN body,
+ ADD COLUMN uuid UUID NOT NULL,
+ ADD COLUMN hbody BYTEA NOT NULL CHECK (LENGTH(hbody)=64),
+ ADD COLUMN salt BYTEA NOT NULL CHECK (LENGTH(salt)=16),
+ ALTER COLUMN tan_channel SET NOT NULL,
+ ALTER COLUMN tan_info SET NOT NULL;
+COMMENT ON COLUMN tan_challenges.hbody
+ IS 'Salted hash of the body of the original request that triggered the challenge, to be replayed once the challenge is satisfied.';
+COMMENT ON COLUMN tan_challenges.salt
+ IS 'Salt used when hashing the original body.';
+
+CREATE INDEX tan_challenges_uuid_index ON tan_challenges (uuid);
+
COMMIT;
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -58,6 +58,12 @@ BEGIN
END;
$$;
+CREATE OR REPLACE FUNCTION sort_uniq(anyarray)
+RETURNS anyarray LANGUAGE SQL IMMUTABLE AS $$
+ SELECT COALESCE(array_agg(DISTINCT x ORDER BY x), $1[0:0])
+ FROM unnest($1) AS t(x);
+$$;
+
CREATE FUNCTION amount_normalize(
IN amount taler_amount
,OUT normalized taler_amount
@@ -237,7 +243,7 @@ DECLARE
local_customer_id INT8;
BEGIN
-- Get account id and check if 2FA is required
-SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL)
+SELECT customer_id, NOT in_is_tan AND cardinality(tan_channels) > 0
INTO local_customer_id, out_tan_required
FROM customers JOIN bank_accounts ON owning_customer_id = customer_id
WHERE username = in_username AND deleted_at IS NULL;
@@ -601,8 +607,8 @@ BEGIN
-- check if account exists, has zero balance and if 2FA is required
SELECT
customer_id
- ,(NOT in_is_tan AND tan_channel IS NOT NULL)
- ,((balance).val != 0 OR (balance).frac != 0)
+ ,NOT in_is_tan AND cardinality(tan_channels) > 0
+ ,(balance).val != 0 OR (balance).frac != 0
INTO
my_customer_id
,out_tan_required
@@ -1019,7 +1025,7 @@ IF NOT FOUND OR out_creditor_admin THEN
RETURN;
END IF;
-- Find debit bank account ID and check it's a different account and if 2FA is required
-SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id, NOT in_is_tan AND tan_channel IS NOT NULL
+SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id, NOT in_is_tan AND cardinality(tan_channels) > 0
INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account, out_tan_required
FROM bank_accounts
JOIN customers ON customer_id=owning_customer_id
@@ -1304,7 +1310,7 @@ SELECT
selected_exchange_payto,
wallet_bank_account,
(amount).val, (amount).frac,
- (NOT in_is_tan AND tan_channel IS NOT NULL),
+ NOT in_is_tan AND cardinality(tan_channels) > 0,
amount IS NULL AND in_amount IS NULL,
amount != in_amount
INTO
@@ -1485,7 +1491,7 @@ SELECT
bank_account_id, is_taler_exchange, conversion_rate_class_id,
-- Remove potential residual query string an add the receiver_name
split_part(cashout_payto, '?', 1) || '?receiver-name=' || url_encode(name),
- (NOT in_is_tan AND tan_channel IS NOT NULL)
+ NOT in_is_tan AND cardinality(tan_channels) > 0
INTO
account_id, out_account_is_exchange, account_conversion_rate_class_id,
account_cashout_payto, out_tan_required
@@ -1589,61 +1595,15 @@ INSERT INTO libeufin_nexus.initiated_outgoing_transactions (
CALL stats_register_payment('cashout', NULL, in_amount_debit, in_amount_credit);
END $$;
-CREATE FUNCTION tan_challenge_create (
- IN in_body TEXT,
- IN in_op op_enum,
- IN in_code TEXT,
- IN in_timestamp INT8,
- IN in_validity_period INT8,
- IN in_retry_counter INT4,
- IN in_username TEXT,
- IN in_tan_channel tan_enum,
- IN in_tan_info TEXT,
- OUT out_challenge_id INT8
-)
-LANGUAGE plpgsql as $$
-DECLARE
-account_id INT8;
-BEGIN
--- Retrieve account id
-SELECT customer_id INTO account_id FROM customers WHERE username = in_username AND deleted_at IS NULL;
--- Create challenge
-INSERT INTO tan_challenges (
- body,
- op,
- code,
- creation_date,
- expiration_date,
- retry_counter,
- customer,
- tan_channel,
- tan_info
-) VALUES (
- in_body,
- in_op,
- in_code,
- in_timestamp,
- in_timestamp + in_validity_period,
- in_retry_counter,
- account_id,
- in_tan_channel,
- in_tan_info
-) RETURNING challenge_id INTO out_challenge_id;
-END $$;
-COMMENT ON FUNCTION tan_challenge_create IS 'Create a new challenge, return the generated id';
-
CREATE FUNCTION tan_challenge_send(
- IN in_challenge_id INT8,
- IN in_username TEXT,
+ 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_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
@@ -1652,52 +1612,38 @@ CREATE FUNCTION tan_challenge_send(
)
LANGUAGE plpgsql as $$
DECLARE
-account_id INT8;
expired BOOLEAN;
retransmit BOOLEAN;
BEGIN
--- Retrieve account id
-SELECT customer_id, tan_channel, CASE tan_channel
- WHEN 'sms' THEN phone
- WHEN 'email' THEN email
- END
-INTO account_id, out_tan_channel, out_tan_info
-FROM customers WHERE username = in_username AND deleted_at IS NULL;
-
-- 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
- ,NOT in_auth AND op != 'create_token'
,code
- ,COALESCE(tan_channel, out_tan_channel)
- ,COALESCE(tan_info, out_tan_info)
+ ,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
- WHERE customer = account_id
+ 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_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;
+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 OR out_auth_required THEN
- IF out_no_op AND NOT in_auth THEN
- out_no_op=false;
- out_auth_required=true;
- END IF;
- RETURN;
-END IF;
+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 challenge_id = in_challenge_id;
+ WHERE uuid = in_uuid;
out_tan_code = in_code;
ELSIF NOT retransmit THEN
out_tan_code = NULL;
@@ -1706,32 +1652,28 @@ 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_challenge_id INT8,
+ IN in_uuid UUID,
IN in_timestamp INT8,
IN in_retransmission_period INT8
) RETURNS void
LANGUAGE sql AS $$
UPDATE tan_challenges SET
retransmission_date = in_timestamp + in_retransmission_period
- WHERE challenge_id = in_challenge_id;
+ WHERE uuid = in_uuid;
$$;
COMMENT ON FUNCTION tan_challenge_mark_sent IS 'Register a challenge as successfully sent';
CREATE FUNCTION tan_challenge_try (
- IN in_challenge_id INT8,
- IN in_username TEXT,
+ IN in_uuid UUID,
IN in_code TEXT,
IN in_timestamp INT8,
- IN in_auth BOOLEAN,
-- Error status
OUT out_ok BOOLEAN,
OUT out_no_op BOOLEAN,
OUT out_no_retry BOOLEAN,
OUT out_expired BOOLEAN,
- OUT out_auth_required BOOLEAN,
-- Success return
OUT out_op op_enum,
- OUT out_body TEXT,
OUT out_channel tan_enum,
OUT out_info TEXT
)
@@ -1740,22 +1682,6 @@ DECLARE
account_id INT8;
token_creation BOOLEAN;
BEGIN
--- Retrieve account id
-SELECT customer_id INTO account_id FROM customers WHERE username = in_username AND deleted_at IS NULL;
-
--- Check if challenge exist and if auth is required
-SELECT NOT in_auth AND op != 'create_token', op = 'create_token'
-INTO out_auth_required, token_creation
-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
- IF out_no_op AND NOT in_auth THEN
- out_no_op=false;
- out_auth_required=true;
- END IF;
- RETURN;
-END IF;
-- Try to solve challenge
UPDATE tan_challenges SET
@@ -1764,12 +1690,14 @@ UPDATE tan_challenges SET
ELSE confirmation_date
END,
retry_counter = retry_counter - 1
-WHERE challenge_id = in_challenge_id
+WHERE uuid = in_uuid
RETURNING
confirmation_date IS NOT NULL,
retry_counter <= 0 AND confirmation_date IS NULL,
- in_timestamp >= expiration_date AND confirmation_date IS NULL
-INTO out_ok, out_no_retry, out_expired;
+ in_timestamp >= expiration_date AND confirmation_date IS NULL,
+ op = 'create_token',
+ customer
+INTO out_ok, out_no_retry, out_expired, token_creation, account_id;
out_no_op = NOT FOUND;
IF NOT out_ok AND token_creation THEN
@@ -1781,9 +1709,9 @@ IF out_no_op OR NOT out_ok OR out_no_retry OR out_expired THEN
END IF;
-- Recover body and op from challenge
-SELECT body, op, tan_channel, tan_info
- INTO out_body, out_op, out_channel, out_info
- FROM tan_challenges WHERE challenge_id = in_challenge_id;
+SELECT op, tan_channel, tan_info
+ INTO out_op, out_channel, out_info
+ FROM tan_challenges WHERE uuid = in_uuid;
END $$;
COMMENT ON FUNCTION tan_challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed';