libeufin

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

commit d3ef68d4dd63c8b9e3dd59d40c70ff88d547b086
parent 8b54f4011b71360ba7e1afeab11af4bc5270c7f5
Author: Antoine A <>
Date:   Wed,  8 Oct 2025 15:22:46 +0200

bank: new 2FA API

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 274++++++++++++++++++++++++++++++++++++++-----------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt | 117-------------------------------------------------------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 177+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt | 8--------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 139++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 10++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 4++--
Mbank/src/test/kotlin/CoreBankApiTest.kt | 226+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 85+++++++++++++++++++++++++++++++++++--------------------------------------------
Mbank/src/test/kotlin/GcTest.kt | 6++----
Mbank/src/test/kotlin/bench.kt | 19++++++++++++-------
Mbank/src/test/kotlin/helpers.kt | 50+++++++++++++++++++++++++++++++++++++-------------
Mcommon/src/main/kotlin/Constants.kt | 3++-
Mcommon/src/main/kotlin/TalerCommon.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/api/server.kt | 8++++++++
Mcommon/src/main/kotlin/crypto/CryptoUtil.kt | 20+++++++++++++++++++-
Mcommon/src/main/kotlin/db/statement.kt | 24++++++++++++++++++++++++
Mcommon/src/main/kotlin/db/types.kt | 8++++++++
Mdatabase-versioning/libeufin-bank-0011.sql | 1-
Mdatabase-versioning/libeufin-bank-0014.sql | 24++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 140+++++++++++++++++++------------------------------------------------------------
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';