summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-01-12 16:10:01 +0000
committerAntoine A <>2024-01-12 16:10:01 +0000
commit1a3ebf8f8aeb3f6a941197fe3bfc85360bbc228f (patch)
tree5a76fefcc1ca6d6256e3c893cf3b1ead33339e11
parent0a69b6d2d8ced81e3af507e9af010f6947b33e2d (diff)
parenta93253a3aab5cfe12a9294c2a4f25b73ad0b3873 (diff)
downloadlibeufin-1a3ebf8f8aeb3f6a941197fe3bfc85360bbc228f.tar.gz
libeufin-1a3ebf8f8aeb3f6a941197fe3bfc85360bbc228f.tar.bz2
libeufin-1a3ebf8f8aeb3f6a941197fe3bfc85360bbc228f.zip
Merge 2fa info master
-rw-r--r--API_CHANGES.md11
-rw-r--r--Makefile12
-rw-r--r--bank/conf/test.conf2
-rw-r--r--bank/conf/test_tan_err.conf (renamed from bank/conf/test_no_tan.conf)2
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Constants.kt2
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt382
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Error.kt7
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Main.kt16
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt3
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt41
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Tan.kt64
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt182
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt165
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt1
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt177
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt7
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt31
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/helpers.kt26
-rw-r--r--bank/src/test/kotlin/AmountTest.kt2
-rw-r--r--bank/src/test/kotlin/BankIntegrationApiTest.kt3
-rw-r--r--bank/src/test/kotlin/CoreBankApiTest.kt740
-rw-r--r--bank/src/test/kotlin/DatabaseTest.kt81
-rw-r--r--bank/src/test/kotlin/helpers.kt109
-rw-r--r--bank/src/test/kotlin/routines.kt8
-rw-r--r--database-versioning/libeufin-bank-0001.sql152
-rw-r--r--database-versioning/libeufin-bank-0002.sql90
-rw-r--r--database-versioning/libeufin-bank-drop.sql1
-rw-r--r--database-versioning/libeufin-bank-procedures.sql516
-rw-r--r--database-versioning/libeufin-conversion-setup.sql4
-rw-r--r--integration/test/IntegrationTest.kt8
-rw-r--r--util/src/main/kotlin/TalerErrorCode.kt52
31 files changed, 1727 insertions, 1170 deletions
diff --git a/API_CHANGES.md b/API_CHANGES.md
index 30fc5509..3379c505 100644
--- a/API_CHANGES.md
+++ b/API_CHANGES.md
@@ -19,6 +19,17 @@ This files contains all the API changes for the current release:
- POST /accounts/USERNAME/transactions: prohibit transaction to admin account
- Deprecate POST /accounts/USERNAME/withdrawals/WITHDRAWAL_ID/abort
- Add POST /taler-integration/withdrawal-operation/WITHDRAWAL_ID/abort
+- Add 2FA logic
+- Remove POST /accounts/USERNAME/cashouts/CASHOUT_ID/abort
+- Remove POST /accounts/USERNAME/cashouts/CASHOUT_ID/confirm
+- Add POST /accounts/USERNAME/challenge/CHALLENGE_ID
+- Add POST /accounts/USERNAME/challenge/CHALLENGE_ID/confirm
+- POST /accounts/USERNAME/cashouts: remove tan_channel field
+- POST /accounts/USERNAME/cashouts/CASHOUT_ID: remove confirmation_time, tan_channel, tan_info and status fields
+- POST /accounts/$USERNAME/cashouts: remove status field
+- POST /cashouts: remove status field
+- PATCH /accounts/USERNAME: add tan_channel
+- GET /accounts/USERNAME: add tan_channel
## bank cli
diff --git a/Makefile b/Makefile
index 343a6660..94d79851 100644
--- a/Makefile
+++ b/Makefile
@@ -94,6 +94,11 @@ install:
assemble:
./gradlew assemble
+.PHONY: doc
+doc:
+ ./gradlew dokkaHtmlMultiModule
+ open build/dokka/htmlMultiModule/index.html
+
.PHONY: check
check: install-nobuild-bank-files
./gradlew check
@@ -102,7 +107,6 @@ check: install-nobuild-bank-files
test: install-nobuild-bank-files
./gradlew test --tests $(test) -i
-.PHONY: doc
-doc:
- ./gradlew dokkaHtmlMultiModule
- open build/dokka/htmlMultiModule/index.html
+.PHONY: integration-test
+integration-test: install-nobuild-bank-files
+ ./gradlew :integration:test --tests $(test) -i
diff --git a/bank/conf/test.conf b/bank/conf/test.conf
index b4eb3953..4a7a4476 100644
--- a/bank/conf/test.conf
+++ b/bank/conf/test.conf
@@ -8,7 +8,7 @@ ALLOW_EDIT_CASHOUT_PAYTO_URI = yes
allow_conversion = YES
FIAT_CURRENCY = EUR
tan_sms = libeufin-tan-file.sh
-tan_email = libeufin-tan-fail.sh
+tan_email = libeufin-tan-file.sh
[libeufin-bankdb-postgres]
CONFIG = postgresql:///libeufincheck
diff --git a/bank/conf/test_no_tan.conf b/bank/conf/test_tan_err.conf
index 52e824b2..faaf9883 100644
--- a/bank/conf/test_no_tan.conf
+++ b/bank/conf/test_tan_err.conf
@@ -6,6 +6,8 @@ FIAT_CURRENCY = EUR
ALLOW_REGISTRATION = yes
ALLOW_ACCOUNT_DELETION = yes
ALLOW_EDIT_CASHOUT_PAYTO_URI = yes
+tan_sms = libeufin-tan-fail.sh
+tan_email =
[libeufin-bankdb-postgres]
CONFIG = postgresql:///libeufincheck
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
index 5567e13c..d61d767e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -44,7 +44,7 @@ const val MIN_VERSION: Int = 14
const val SERIALIZATION_RETRY: Int = 10;
// API version
-const val COREBANK_API_VERSION: String = "3:0:3"
+const val COREBANK_API_VERSION: String = "4:0:0"
const val CONVERSION_API_VERSION: String = "0:0:0"
const val INTEGRATION_API_VERSION: String = "2:0:2"
const val WIRE_GATEWAY_API_VERSION: String = "0:1:0" \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
index bc8b88b6..a7d0f2c6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -28,6 +28,7 @@ import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
import kotlin.random.Random
+import kotlinx.serialization.json.Json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.await
import kotlinx.coroutines.withContext
@@ -35,8 +36,10 @@ import net.taler.common.errorcodes.TalerErrorCode
import net.taler.wallet.crypto.Base32Crockford
import org.slf4j.Logger
import org.slf4j.LoggerFactory
+import tech.libeufin.bank.*
import tech.libeufin.bank.auth.*
import tech.libeufin.bank.db.*
+import tech.libeufin.bank.db.TanDAO.*
import tech.libeufin.bank.db.AccountDAO.*
import tech.libeufin.bank.db.CashoutDAO.*
import tech.libeufin.bank.db.ExchangeDAO.*
@@ -73,6 +76,7 @@ fun Routing.coreBankApi(db: Database, ctx: BankConfig) {
coreBankTransactionsApi(db, ctx)
coreBankWithdrawalApi(db, ctx)
coreBankCashoutApi(db, ctx)
+ coreBankTanApi(db, ctx)
}
private fun Routing.coreBankTokenApi(db: Database) {
@@ -144,29 +148,49 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq
TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
)
- if (req.debit_threshold != null && !isAdmin)
- throw conflict(
- "only admin account can choose the debit limit",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
- )
+ if (!isAdmin) {
+ if (req.debit_threshold != null)
+ throw conflict(
+ "only admin account can choose the debit limit",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
+ )
+
+ if (req.tan_channel != null)
+ throw conflict(
+ "only admin account can enable 2fa on creation",
+ TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL
+ )
+ } else if (req.tan_channel != null) {
+ if (ctx.tanChannels.get(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
+ }
+ if (missing)
+ throw conflict(
+ "missing info for tan channel ${req.tan_channel}",
+ TalerErrorCode.BANK_MISSING_TAN_INFO
+ )
+ }
+
if (req.username == "exchange" && !req.is_taler_exchange)
throw conflict(
"'exchange' account must be a taler exchange account",
TalerErrorCode.END
)
- val reqPayto = req.payto_uri ?: req.internal_payto_uri
- val contactData = req.contact_data ?: req.challenge_contact_data
- var retry = if (reqPayto == null) IBAN_ALLOCATION_RETRY_COUNTER else 0
+ var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0
while (true) {
- val internalPayto = reqPayto ?: IbanPayTo(genIbanPaytoUri())
+ val internalPayto = req.payto_uri ?: IbanPayTo(genIbanPaytoUri())
val res = db.account.create(
login = req.username,
name = req.name,
- email = contactData?.email?.get(),
- phone = contactData?.phone?.get(),
+ email = req.contact_data?.email?.get(),
+ phone = req.contact_data?.phone?.get(),
cashoutPayto = req.cashout_payto_uri,
password = req.password,
internalPaytoUri = internalPayto,
@@ -175,7 +199,8 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq
maxDebt = req.debit_threshold ?: ctx.defaultDebtLimit,
bonus = if (!req.is_taler_exchange) ctx.registrationBonus
else TalerAmount(0, 0, ctx.regionalCurrency),
- checkPaytoIdempotent = req.internal_payto_uri != null
+ tanChannel = req.tan_channel,
+ checkPaytoIdempotent = req.payto_uri != null
)
// Retry with new IBAN
if (res == AccountCreationResult.PayToReuse && retry > 0) {
@@ -186,25 +211,41 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq
}
}
-suspend fun patchAccount(db: Database, ctx: BankConfig, req: AccountReconfiguration, username: String, isAdmin: Boolean): AccountPatchResult {
+suspend fun patchAccount(
+ db: Database,
+ ctx: BankConfig,
+ req: AccountReconfiguration,
+ username: String,
+ isAdmin: Boolean,
+ is2fa: Boolean,
+ channel: TanChannel? = null,
+ info: String? = null
+): AccountPatchResult {
req.debit_threshold?.run { ctx.checkRegionalCurrency(this) }
- val contactData = req.contact_data ?: req.challenge_contact_data
if (username == "admin" && req.is_public == true)
throw conflict(
"'admin' account cannot be public",
TalerErrorCode.END
)
+
+ if (req.tan_channel is Option.Some && req.tan_channel.value != null && ctx.tanChannels.get(req.tan_channel.value ) == null) {
+ throw unsupportedTanChannel(req.tan_channel.value)
+ }
return db.account.reconfig(
login = username,
name = req.name,
cashoutPayto = req.cashout_payto_uri,
- email = contactData?.email ?: Option.None,
- phone = contactData?.phone ?: Option.None,
+ 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,
isAdmin = isAdmin,
+ is2fa = is2fa,
+ faChannel = channel,
+ faInfo = info,
allowEditName = ctx.allowEditName,
allowEditCashout = ctx.allowEditCashout
)
@@ -239,6 +280,8 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
requireAdmin = !ctx.allowAccountDeletion
) {
delete("/accounts/{USERNAME}") {
+ val challenge = call.challenge(db, Operation.account_delete)
+
// Not deleting reserved names.
if (RESERVED_ACCOUNTS.contains(username))
throw conflict(
@@ -251,21 +294,26 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
)
- when (db.account.delete(username)) {
+ when (db.account.delete(username, isAdmin || challenge != null)) {
AccountDeletionResult.UnknownAccount -> throw unknownAccount(username)
AccountDeletionResult.BalanceNotZero -> throw conflict(
"Account balance is not zero.",
TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
)
+ AccountDeletionResult.TanRequired -> call.respondChallenge(db, Operation.account_delete, Unit)
AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent)
}
}
}
auth(db, TokenScope.readwrite, allowAdmin = true) {
patch("/accounts/{USERNAME}") {
- val req = call.receive<AccountReconfiguration>()
- when (patchAccount(db, ctx, req, username, isAdmin)) {
+ val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
+ val res = patchAccount(db, ctx, req, username, isAdmin, challenge != null, challenge?.channel, challenge?.info)
+ when (res) {
AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
+ is AccountPatchResult.TanRequired -> {
+ call.respondChallenge(db, Operation.account_reconfig, req, res.channel, res.info)
+ }
AccountPatchResult.UnknownAccount -> throw unknownAccount(username)
AccountPatchResult.NonAdminName -> throw conflict(
"non-admin user cannot change their legal name",
@@ -279,22 +327,24 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
"non-admin user cannot change their debt limit",
TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
)
- AccountPatchResult.NonAdminContact -> throw conflict(
- "non-admin user cannot change their contact info",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
+ AccountPatchResult.MissingTanInfo -> throw conflict(
+ "missing info for tan channel ${req.tan_channel.get()}",
+ TalerErrorCode.BANK_MISSING_TAN_INFO
)
}
}
patch("/accounts/{USERNAME}/auth") {
- val req = call.receive<AccountPasswordChange>()
+ val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig)
+
if (!isAdmin && req.old_password == null) {
throw conflict(
"non-admin user cannot change password without providing old password",
TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD
)
}
- when (db.account.reconfigPassword(username, req.new_password, req.old_password)) {
+ when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null)) {
AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent)
+ AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req)
AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(username)
AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
"old password does not match",
@@ -356,33 +406,39 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) {
}
auth(db, TokenScope.readwrite) {
post("/accounts/{USERNAME}/transactions") {
- val tx = call.receive<TransactionCreateRequest>()
- val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
- val amount =
- tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount")
+ val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction)
+
+ val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
+ val amount = req.payto_uri.amount ?: req.amount ?: throw badRequest("Wire transfer lacks amount")
+
ctx.checkRegionalCurrency(amount)
+
val res = db.transaction.create(
- creditAccountPayto = tx.payto_uri,
+ creditAccountPayto = req.payto_uri,
debitAccountUsername = username,
subject = subject,
amount = amount,
timestamp = Instant.now(),
+ is2fa = challenge != null
)
when (res) {
- is BankTransactionResult.UnknownDebtor -> throw unknownAccount(username)
- is BankTransactionResult.BothPartySame -> throw conflict(
+ BankTransactionResult.UnknownDebtor -> throw unknownAccount(username)
+ BankTransactionResult.TanRequired -> {
+ call.respondChallenge(db, Operation.bank_transaction, req)
+ }
+ BankTransactionResult.BothPartySame -> throw conflict(
"Wire transfer attempted with credit and debit party being the same bank account",
TalerErrorCode.BANK_SAME_ACCOUNT
)
- is BankTransactionResult.UnknownCreditor -> throw conflict(
+ BankTransactionResult.UnknownCreditor -> throw conflict(
"Creditor account was not found",
TalerErrorCode.BANK_UNKNOWN_CREDITOR
)
- is BankTransactionResult.AdminCreditor -> throw conflict(
+ BankTransactionResult.AdminCreditor -> throw conflict(
"Cannot transfer money to admin account",
TalerErrorCode.BANK_ADMIN_CREDITOR
)
- is BankTransactionResult.BalanceInsufficient -> throw conflict(
+ BankTransactionResult.BalanceInsufficient -> throw conflict(
"Insufficient funds",
TalerErrorCode.BANK_UNALLOWED_DEBIT
)
@@ -398,7 +454,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
val req = call.receive<BankAccountCreateWithdrawalRequest>()
ctx.checkRegionalCurrency(req.amount)
val opId = UUID.randomUUID()
- when (db.withdrawal.create(username, opId, req.amount)) {
+ when (db.withdrawal.create(username, opId, req.amount, Instant.now())) {
WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username)
WithdrawalCreationResult.AccountIsExchange -> throw conflict(
"Exchange account cannot perform withdrawal operation",
@@ -420,25 +476,12 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
}
}
}
- post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") {
- val opId = call.uuidParameter("withdrawal_id")
- when (db.withdrawal.abort(opId)) {
- AbortResult.UnknownOperation -> throw notFound(
- "Withdrawal operation $opId not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- AbortResult.AlreadyConfirmed -> throw conflict(
- "Cannot abort confirmed withdrawal",
- TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
- )
- AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") {
- val opId = call.uuidParameter("withdrawal_id")
- when (db.withdrawal.confirm(opId, Instant.now())) {
+ val id = call.uuidParameter("withdrawal_id")
+ val challenge = call.challenge(db, Operation.withdrawal)
+ when (db.withdrawal.confirm(username, id, Instant.now(), challenge != null)) {
WithdrawalConfirmationResult.UnknownOperation -> throw notFound(
- "Withdrawal operation $opId not found",
+ "Withdrawal operation $id not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
)
WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
@@ -457,6 +500,9 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
"Exchange to withdraw from not found",
TalerErrorCode.BANK_UNKNOWN_CREDITOR
)
+ WithdrawalConfirmationResult.TanRequired -> {
+ call.respondChallenge(db, Operation.withdrawal, StoredUUID(id))
+ }
WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
}
}
@@ -470,175 +516,51 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
)
call.respond(op)
}
- post("/withdrawals/{withdrawal_id}/abort") {
- val opId = call.uuidParameter("withdrawal_id")
- when (db.withdrawal.abort(opId)) {
- AbortResult.UnknownOperation -> throw notFound(
- "Withdrawal operation $opId not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- AbortResult.AlreadyConfirmed -> throw conflict(
- "Cannot abort confirmed withdrawal",
- TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
- )
- AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- post("/withdrawals/{withdrawal_id}/confirm") {
- val opId = call.uuidParameter("withdrawal_id")
- when (db.withdrawal.confirm(opId, Instant.now())) {
- WithdrawalConfirmationResult.UnknownOperation -> throw notFound(
- "Withdrawal operation $opId not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
- "Cannot confirm an aborted withdrawal",
- TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
- )
- WithdrawalConfirmationResult.NotSelected -> throw conflict(
- "Cannot confirm an unselected withdrawal",
- TalerErrorCode.BANK_CONFIRM_INCOMPLETE
- )
- WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict(
- "Insufficient funds",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- WithdrawalConfirmationResult.UnknownExchange -> throw conflict(
- "Exchange to withdraw from not found",
- TalerErrorCode.BANK_UNKNOWN_CREDITOR
- )
- WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
}
private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) {
auth(db, TokenScope.readwrite) {
post("/accounts/{USERNAME}/cashouts") {
- val req = call.receive<CashoutRequest>()
+ val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout)
ctx.checkRegionalCurrency(req.amount_debit)
ctx.checkFiatCurrency(req.amount_credit)
-
- val tanChannel = req.tan_channel ?: TanChannel.sms
- val tanScript = ctx.tanChannels.get(tanChannel) ?: throw libeufinError(
- HttpStatusCode.NotImplemented,
- "Unsupported tan channel $tanChannel",
- TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
- )
-
+
val res = db.cashout.create(
login = username,
requestUid = req.request_uid,
amountDebit = req.amount_debit,
amountCredit = req.amount_credit,
subject = req.subject ?: "", // TODO default subject
- tanChannel = tanChannel,
- tanCode = Tan.genCode(),
- now = Instant.now(),
- retryCounter = TAN_RETRY_COUNTER,
- validityPeriod = TAN_VALIDITY_PERIOD
+ now = Instant.now(),
+ is2fa = challenge != null
)
when (res) {
- is CashoutCreationResult.AccountNotFound -> throw unknownAccount(username)
- is CashoutCreationResult.BadConversion -> throw conflict(
+ CashoutCreationResult.AccountNotFound -> throw unknownAccount(username)
+ CashoutCreationResult.BadConversion -> throw conflict(
"Wrong currency conversion",
TalerErrorCode.BANK_BAD_CONVERSION
)
- is CashoutCreationResult.AccountIsExchange -> throw conflict(
+ CashoutCreationResult.AccountIsExchange -> throw conflict(
"Exchange account cannot perform cashout operation",
TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
)
- is CashoutCreationResult.BalanceInsufficient -> throw conflict(
+ CashoutCreationResult.BalanceInsufficient -> throw conflict(
"Insufficient funds to withdraw with Taler",
TalerErrorCode.BANK_UNALLOWED_DEBIT
)
- is CashoutCreationResult.MissingTanInfo -> throw conflict(
- "Account '$username' missing info for tan channel ${req.tan_channel}",
- TalerErrorCode.BANK_MISSING_TAN_INFO
- )
- is CashoutCreationResult.RequestUidReuse -> throw conflict(
+ CashoutCreationResult.RequestUidReuse -> throw conflict(
"request_uid used already",
TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
)
- is CashoutCreationResult.Success -> {
- res.tanCode?.run {
- val exitValue = withContext(Dispatchers.IO) {
- val process = ProcessBuilder(tanScript, res.tanInfo).start()
- try {
- process.outputWriter().use { it.write(res.tanCode) }
- process.onExit().await()
- } catch (e: Exception) {
- process.destroy()
- }
- process.exitValue()
- }
- if (exitValue != 0) {
- throw libeufinError(
- HttpStatusCode.BadGateway,
- "Tan channel script failure with exit value $exitValue",
- TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
- )
- }
- db.cashout.markSent(res.id, Instant.now(), TAN_RETRANSMISSION_PERIOD, tanChannel, res.tanInfo)
- }
- call.respond(CashoutPending(res.id))
- }
- }
- }
- post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") {
- val id = call.longParameter("CASHOUT_ID")
- when (db.cashout.abort(id, username)) {
- AbortResult.UnknownOperation -> throw notFound(
- "Cashout operation $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- AbortResult.AlreadyConfirmed -> throw conflict(
- "Cannot abort confirmed cashout",
- TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
- )
- AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") {
- val req = call.receive<CashoutConfirm>()
- val id = call.longParameter("CASHOUT_ID")
- when (db.cashout.confirm(
- id = id,
- login = username,
- tanCode = req.tan,
- timestamp = Instant.now()
- )) {
- CashoutConfirmationResult.OP_NOT_FOUND -> throw notFound(
- "Cashout operation $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- CashoutConfirmationResult.ABORTED -> throw conflict(
- "Cannot confirm an aborted cashout",
- TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
- )
- CashoutConfirmationResult.BAD_TAN_CODE -> throw conflict(
- "Incorrect TAN code",
- TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
- )
- CashoutConfirmationResult.NO_RETRY -> throw libeufinError(
- HttpStatusCode.TooManyRequests,
- "Too many failed confirmation attempt",
- TalerErrorCode.BANK_TAN_RATE_LIMITED
- )
- CashoutConfirmationResult.NO_CASHOUT_PAYTO -> throw conflict(
+ CashoutCreationResult.NoCashoutPayto -> throw conflict(
"Missing cashout payto uri",
TalerErrorCode.BANK_CONFIRM_INCOMPLETE
)
- CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict(
- "Insufficient funds",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- CashoutConfirmationResult.BAD_CONVERSION -> throw conflict(
- "Wrong currency conversion",
- TalerErrorCode.BANK_BAD_CONVERSION
- )
- CashoutConfirmationResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
+ CashoutCreationResult.TanRequired -> {
+ call.respondChallenge(db, Operation.cashout, req)
+ }
+ is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id))
}
}
}
@@ -673,3 +595,83 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio
}
}
}
+
+private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
+ auth(db, TokenScope.readwrite) {
+ post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
+ val id = call.longParameter("CHALLENGE_ID")
+ val res = db.tan.send(
+ id = id,
+ login = username,
+ code = Tan.genCode(),
+ now = Instant.now(),
+ retryCounter = TAN_RETRY_COUNTER,
+ validityPeriod = TAN_VALIDITY_PERIOD
+ )
+ when (res) {
+ TanSendResult.NotFound -> throw notFound(
+ "Challenge $id not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ is TanSendResult.Success -> {
+ res.tanCode?.run {
+ val tanScript = ctx.tanChannels.get(res.tanChannel)
+ ?: throw unsupportedTanChannel(res.tanChannel)
+ val exitValue = withContext(Dispatchers.IO) {
+ val process = ProcessBuilder(tanScript, res.tanInfo).start()
+ try {
+ process.outputWriter().use { it.write(res.tanCode) }
+ process.onExit().await()
+ } catch (e: Exception) {
+ process.destroy()
+ }
+ process.exitValue()
+ }
+ if (exitValue != 0) {
+ throw libeufinError(
+ HttpStatusCode.BadGateway,
+ "Tan channel script failure with exit value $exitValue",
+ TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
+ )
+ }
+ db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD)
+ }
+ call.respond(TanTransmission(
+ tan_info = res.tanInfo,
+ tan_channel = res.tanChannel
+ ))
+ }
+ }
+ }
+ post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
+ val id = call.longParameter("CHALLENGE_ID")
+ val req = call.receive<ChallengeSolve>()
+ val res = db.tan.solve(
+ id = id,
+ login = username,
+ code = req.tan,
+ now = Instant.now()
+ )
+ 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 libeufinError(
+ HttpStatusCode.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/Error.kt b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
index 68636fbb..04c3881e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
@@ -145,4 +145,11 @@ fun unknownAccount(id: String): LibeufinException {
"Account '$id' not found",
TalerErrorCode.BANK_UNKNOWN_ACCOUNT
)
+}
+
+fun unsupportedTanChannel(channel: TanChannel): LibeufinException {
+ return conflict(
+ "Unsupported tan channel $channel",
+ TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
+ )
} \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 50d47429..042ef833 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -144,6 +144,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
when (cause) {
is LibeufinException -> call.err(cause)
is SQLException -> {
+ logger.debug("request failed", cause)
when (cause.sqlState) {
PSQLState.SERIALIZATION_FAILURE.state -> call.err(
HttpStatusCode.InternalServerError,
@@ -190,6 +191,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
)
}
else -> {
+ logger.debug("request failed", cause)
call.err(
HttpStatusCode.InternalServerError,
cause.message,
@@ -331,11 +333,12 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") {
val dbCfg = cfg.loadDbConfig()
val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency)
runBlocking {
- val res = db.account.reconfigPassword(username, password, null)
+ val res = db.account.reconfigPassword(username, password, null, true)
when (res) {
AccountPatchAuthResult.UnknownAccount ->
throw Exception("Password change for '$username' account failed: unknown account")
- AccountPatchAuthResult.OldPasswordMismatch -> { /* Can never happen */ }
+ AccountPatchAuthResult.OldPasswordMismatch,
+ AccountPatchAuthResult.TanRequired -> { /* Can never happen */ }
AccountPatchAuthResult.Success ->
logger.info("Password change for '$username' account succeeded")
}
@@ -365,6 +368,7 @@ class EditAccount : CliktCommand(
).boolean()
private val email: String? by option(help = "E-Mail address used for TAN transmission")
private val phone: String? by option(help = "Phone number used for TAN transmission")
+ private val tan_channel: String? by option(help = "which channel TAN challenges should be sent to")
private val cashout_payto_uri: IbanPayTo? by option(help = "Payto URI of a fiant account who receive cashout amount").convert { IbanPayTo(it) }
private val debit_threshold: TalerAmount? by option(help = "Max debit allowed for this account").convert { TalerAmount(it) }
@@ -386,15 +390,17 @@ class EditAccount : CliktCommand(
cashout_payto_uri = Option.Some(cashout_payto_uri),
debit_threshold = debit_threshold
)
- when (patchAccount(db, ctx, req, username, true)) {
+ when (patchAccount(db, ctx, req, username, true, false)) {
AccountPatchResult.Success ->
logger.info("Account '$username' edited")
AccountPatchResult.UnknownAccount ->
throw Exception("Account '$username' not found")
+ AccountPatchResult.MissingTanInfo ->
+ throw Exception("missing info for tan channel ${req.tan_channel.get()}")
AccountPatchResult.NonAdminName,
AccountPatchResult.NonAdminCashout,
AccountPatchResult.NonAdminDebtLimit,
- AccountPatchResult.NonAdminContact -> {
+ is AccountPatchResult.TanRequired -> {
// Unreachable as we edit account as admin
}
}
@@ -444,6 +450,7 @@ class CreateAccount : CliktCommand(
private val options by CreateAccountOption().cooccurring()
override fun run() = cliCmd(logger) {
+ // TODO support setting tan
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
val dbCfg = cfg.loadDbConfig()
@@ -461,7 +468,6 @@ class CreateAccount : CliktCommand(
phone = Option.Some(phone),
),
cashout_payto_uri = cashout_payto_uri,
- internal_payto_uri = internal_payto_uri,
payto_uri = payto_uri,
debit_threshold = debit_threshold
)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
index 1322fe5f..6f42f5cb 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
@@ -28,8 +28,7 @@ import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
import java.util.concurrent.TimeUnit
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.Serializable
+import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
index c827d406..c94fa09e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -72,6 +72,15 @@ enum class Timeframe {
year
}
+enum class Operation {
+ account_reconfig,
+ account_delete,
+ account_auth_reconfig,
+ bank_transaction,
+ cashout,
+ withdrawal
+}
+
@Serializable(with = Option.Serializer::class)
sealed class Option<out T> {
object None : Option<Nothing>()
@@ -111,6 +120,17 @@ sealed class Option<out T> {
}
}
+@Serializable
+data class TanChallenge(
+ val challenge_id: Long
+)
+
+@Serializable
+data class TanTransmission(
+ val tan_info: String,
+ val tan_channel: TanChannel
+)
+
/**
* HTTP response type of successful token refresh.
* access_token is the Crockford encoding of the 32 byte
@@ -156,9 +176,7 @@ data class RegisterAccountRequest(
val cashout_payto_uri: IbanPayTo? = null,
val payto_uri: IbanPayTo? = null,
val debit_threshold: TalerAmount? = null,
- // TODO remove
- val internal_payto_uri: IbanPayTo? = null,
- val challenge_contact_data: ChallengeContactData? = null,
+ val tan_channel: TanChannel? = null,
)
@Serializable
@@ -176,8 +194,7 @@ data class AccountReconfiguration(
val name: String? = null,
val is_public: Boolean? = null,
val debit_threshold: TalerAmount? = null,
- // TODO remove
- val challenge_contact_data: ChallengeContactData? = null,
+ val tan_channel: Option<TanChannel?> = Option.None,
val is_taler_exchange: Boolean? = null,
)
@@ -335,6 +352,7 @@ data class AccountData(
val debit_threshold: TalerAmount,
val contact_data: ChallengeContactData? = null,
val cashout_payto_uri: String? = null,
+ val tan_channel: TanChannel? = null,
val is_public: Boolean,
val is_taler_exchange: Boolean
)
@@ -389,10 +407,6 @@ data class WithdrawalPublicInfo (
val username: String,
val selected_reserve_pub: EddsaPublicKey? = null,
val selected_exchange_account: String? = null,
- // TODO remove
- val aborted: Boolean,
- val confirmation_done: Boolean,
- val selection_done: Boolean,
)
@Serializable
@@ -447,12 +461,11 @@ data class CashoutRequest(
val request_uid: ShortHashCode,
val subject: String?,
val amount_debit: TalerAmount,
- val amount_credit: TalerAmount,
- val tan_channel: TanChannel?
+ val amount_credit: TalerAmount
)
@Serializable
-data class CashoutPending(
+data class CashoutResponse(
val cashout_id: Long,
)
@@ -493,7 +506,7 @@ data class CashoutStatusResponse(
)
@Serializable
-data class CashoutConfirm(
+data class ChallengeSolve(
val tan: String
)
@@ -628,8 +641,6 @@ data class PublicAccount(
val payto_uri: String,
val balance: Balance,
val is_taler_exchange: Boolean,
- // TODO remove
- val account_name: String
)
/**
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
index 8359e5e8..5dddd807 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
@@ -19,8 +19,70 @@
package tech.libeufin.bank
import java.security.SecureRandom
-import java.util.UUID
+import java.time.Instant
+import java.time.Duration
import java.text.DecimalFormat
+import kotlinx.serialization.json.Json
+import io.ktor.http.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.application.*
+import tech.libeufin.bank.db.TanDAO.*
+import tech.libeufin.bank.db.*
+import tech.libeufin.bank.auth.*
+import io.ktor.util.pipeline.PipelineContext
+
+
+inline suspend 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(
+ login = username,
+ op = op,
+ body = json,
+ code = code,
+ now = Instant.now(),
+ retryCounter = TAN_RETRY_COUNTER,
+ validityPeriod = TAN_VALIDITY_PERIOD,
+ channel = channel,
+ info = info
+ )
+ respond(
+ status = HttpStatusCode.Accepted,
+ message = TanChallenge(id)
+ )
+}
+
+inline suspend fun <reified B> ApplicationCall.receiveChallenge(
+ db: Database,
+ op: Operation
+): Pair<B, Challenge?> {
+ val id = request.headers["X-Challenge-Id"]?.toLongOrNull()
+ return if (id != null) {
+ val challenge = db.tan.challenge(id, username, op)!!
+ Pair(Json.decodeFromString(challenge.body), challenge)
+ } else {
+ Pair(this.receive(), null)
+ }
+}
+
+suspend fun ApplicationCall.challenge(
+ db: Database,
+ op: Operation
+): Challenge? {
+ val id = request.headers["X-Challenge-Id"]?.toLongOrNull()
+ return if (id != null) {
+ db.tan.challenge(id, username, op)!!
+ } else {
+ null
+ }
+}
object Tan {
private val CODE_FORMAT = DecimalFormat("00000000");
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
index fac65264..70b9bd1f 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -39,14 +39,15 @@ class AccountDAO(private val db: Database) {
login: String,
password: String,
name: String,
- email: String? = null,
- phone: String? = null,
- cashoutPayto: IbanPayTo? = null,
+ email: String?,
+ phone: String?,
+ cashoutPayto: IbanPayTo?,
internalPaytoUri: IbanPayTo,
isPublic: Boolean,
isTalerExchange: Boolean,
maxDebt: TalerAmount,
bonus: TalerAmount,
+ tanChannel: TanChannel?,
// Whether to check [internalPaytoUri] for idempotency
checkPaytoIdempotent: Boolean
): AccountCreationResult = db.serializable { it ->
@@ -60,11 +61,13 @@ class AccountDAO(private val db: Database) {
AND (NOT ? OR internal_payto_uri=?)
AND is_public=?
AND is_taler_exchange=?
+ AND tan_channel IS NOT DISTINCT FROM ?::tan_enum
FROM customers
JOIN bank_accounts
ON customer_id=owning_customer_id
WHERE login=?
""").run {
+ // TODO check max debt
setString(1, name)
setString(2, email)
setString(3, phone)
@@ -73,11 +76,13 @@ class AccountDAO(private val db: Database) {
setString(6, internalPaytoUri.canonical)
setBoolean(7, isPublic)
setBoolean(8, isTalerExchange)
- setString(9, login)
+ setString(9, tanChannel?.name)
+ setString(10, login)
oneOrNull {
CryptoUtil.checkpw(password, it.getString(1)) && it.getBoolean(2)
}
}
+
if (idempotent != null) {
if (idempotent) {
AccountCreationResult.Success
@@ -85,6 +90,20 @@ class AccountDAO(private val db: Database) {
AccountCreationResult.LoginReuse
}
} else {
+ conn.prepareStatement("""
+ INSERT INTO iban_history(
+ iban
+ ,creation_time
+ ) VALUES (?, ?)
+ """).run {
+ setString(1, internalPaytoUri.iban)
+ setLong(2, now)
+ if (!executeUpdateViolation()) {
+ conn.rollback()
+ return@transaction AccountCreationResult.PayToReuse
+ }
+ }
+
val customerId = conn.prepareStatement("""
INSERT INTO customers (
login
@@ -93,7 +112,8 @@ class AccountDAO(private val db: Database) {
,email
,phone
,cashout_payto
- ) VALUES (?, ?, ?, ?, ?, ?)
+ ,tan_channel
+ ) VALUES (?, ?, ?, ?, ?, ?, ?::tan_enum)
RETURNING customer_id
"""
).run {
@@ -103,22 +123,9 @@ class AccountDAO(private val db: Database) {
setString(4, email)
setString(5, phone)
setString(6, cashoutPayto?.canonical)
+ setString(7, tanChannel?.name)
oneOrNull { it.getLong("customer_id") }!!
}
-
- conn.prepareStatement("""
- INSERT INTO iban_history(
- iban
- ,creation_time
- ) VALUES (?, ?)
- """).run {
- setString(1, internalPaytoUri.iban)
- setLong(2, now)
- if (!executeUpdateViolation()) {
- conn.rollback()
- return@transaction AccountCreationResult.PayToReuse
- }
- }
conn.prepareStatement("""
INSERT INTO bank_accounts(
@@ -144,7 +151,7 @@ class AccountDAO(private val db: Database) {
if (bonus.value != 0L || bonus.frac != 0) {
conn.prepareStatement("""
SELECT out_balance_insufficient
- FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?)
+ FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true)
""").run {
setString(1, internalPaytoUri.canonical)
setLong(2, bonus.value)
@@ -172,36 +179,44 @@ class AccountDAO(private val db: Database) {
enum class AccountDeletionResult {
Success,
UnknownAccount,
- BalanceNotZero
+ BalanceNotZero,
+ TanRequired
}
/** Delete account [login] */
- suspend fun delete(login: String): AccountDeletionResult = db.serializable { conn ->
+ suspend fun delete(
+ login: String,
+ is2fa: Boolean
+ ): AccountDeletionResult = db.serializable { conn ->
val stmt = conn.prepareStatement("""
SELECT
- out_nx_customer,
- out_balance_not_zero
- FROM customer_delete(?);
+ out_not_found,
+ out_balance_not_zero,
+ out_tan_required
+ FROM account_delete(?,?);
""")
stmt.setString(1, login)
+ stmt.setBoolean(2, is2fa)
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("Deletion returned nothing.")
- it.getBoolean("out_nx_customer") -> AccountDeletionResult.UnknownAccount
+ it.getBoolean("out_not_found") -> AccountDeletionResult.UnknownAccount
it.getBoolean("out_balance_not_zero") -> AccountDeletionResult.BalanceNotZero
+ it.getBoolean("out_tan_required") -> AccountDeletionResult.TanRequired
else -> AccountDeletionResult.Success
}
}
}
/** Result status of customer account patch */
- enum class AccountPatchResult {
- UnknownAccount,
- NonAdminName,
- NonAdminCashout,
- NonAdminDebtLimit,
- NonAdminContact,
- Success
+ sealed class AccountPatchResult {
+ data object UnknownAccount: AccountPatchResult()
+ data object NonAdminName: AccountPatchResult()
+ data object NonAdminCashout: AccountPatchResult()
+ data object NonAdminDebtLimit: AccountPatchResult()
+ data object MissingTanInfo: AccountPatchResult()
+ data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult()
+ data object Success: AccountPatchResult()
}
/** Change account [login] informations */
@@ -211,27 +226,33 @@ class AccountDAO(private val db: Database) {
cashoutPayto: Option<IbanPayTo?>,
phone: Option<String?>,
email: Option<String?>,
+ tan_channel: Option<TanChannel?>,
isPublic: Boolean?,
debtLimit: TalerAmount?,
isAdmin: Boolean,
+ is2fa: Boolean,
+ faChannel: TanChannel?,
+ faInfo: String?,
allowEditName: Boolean,
allowEditCashout: Boolean,
): AccountPatchResult = db.serializable { it.transaction { conn ->
val checkName = !isAdmin && !allowEditName && name != null
val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome()
val checkDebtLimit = !isAdmin && debtLimit != null
- val checkPhone = !isAdmin && phone.isSome()
- val checkEmail = !isAdmin && email.isSome()
// Get user ID and check reconfig rights
- val customer_id = conn.prepareStatement("""
+ val (customerId, currChannel, currInfo) = conn.prepareStatement("""
SELECT
customer_id
- ,(${ if (checkName) "name != ? " else "false" }) as name_change
+ ,(${ if (checkName) "name != ?" else "false" }) as name_change
,(${ if (checkCashout) "cashout_payto IS DISTINCT FROM ?" else "false" }) as cashout_change
,(${ if (checkDebtLimit) "max_debt != (?, ?)::taler_amount" else "false" }) as debt_limit_change
- ,(${ if (checkPhone) "phone IS DISTINCT FROM ?" else "false" }) as phone_change
- ,(${ if (checkEmail) "email IS DISTINCT FROM ?" else "false" }) as email_change
+ ,(${ when (tan_channel.get()) {
+ null -> "false"
+ TanChannel.sms -> if (phone.get() != null) "false" else "phone IS NULL"
+ TanChannel.email -> if (email.get() != null) "false" else "email IS NULL"
+ }}) as missing_tan_info
+ ,tan_channel, phone, email
FROM customers
JOIN bank_accounts
ON customer_id=owning_customer_id
@@ -248,12 +269,6 @@ class AccountDAO(private val db: Database) {
setLong(idx, debtLimit!!.value); idx++
setInt(idx, debtLimit.frac); idx++
}
- if (checkPhone) {
- setString(idx, phone.get()); idx++
- }
- if (checkEmail) {
- setString(idx, email.get()); idx++
- }
setString(idx, login)
executeQuery().use {
when {
@@ -261,12 +276,49 @@ class AccountDAO(private val db: Database) {
it.getBoolean("name_change") -> return@transaction AccountPatchResult.NonAdminName
it.getBoolean("cashout_change") -> return@transaction AccountPatchResult.NonAdminCashout
it.getBoolean("debt_limit_change") -> return@transaction AccountPatchResult.NonAdminDebtLimit
- it.getBoolean("phone_change") -> return@transaction AccountPatchResult.NonAdminContact
- it.getBoolean("email_change") -> return@transaction AccountPatchResult.NonAdminContact
- else -> it.getLong("customer_id")
+ it.getBoolean("missing_tan_info") -> return@transaction AccountPatchResult.MissingTanInfo
+ else -> {
+ val currChannel = it.getString("tan_channel")?.run { TanChannel.valueOf(this) }
+ Triple(
+ it.getLong("customer_id"),
+ currChannel,
+ when (tan_channel.get() ?: currChannel) {
+ TanChannel.sms -> it.getString("phone")
+ TanChannel.email -> it.getString("email")
+ null -> null
+ }
+ )
+ }
}
}
}
+
+ val newChannel = tan_channel.get();
+ val newInfo = when (newChannel ?: currChannel) {
+ TanChannel.sms -> phone.get()
+ TanChannel.email -> email.get()
+ null -> null
+ }
+
+ // Tan channel verification
+ if (!isAdmin) {
+ // Check performed 2fa check
+ if (currChannel != null && !is2fa) {
+ // Perform challenge with current settings
+ return@transaction 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 ((newChannel != null && newChannel != faChannel) || (newInfo != null && newInfo != faInfo)) {
+ return@transaction AccountPatchResult.TanRequired(channel = newChannel ?: currChannel, info = newInfo ?: currInfo)
+ }
+ }
+
+ // Invalidate current challenges
+ if (newChannel != null || newInfo != null) {
+ val stmt = conn.prepareStatement("UPDATE tan_challenges SET expiration_date=0 WHERE customer=?")
+ stmt.setLong(1, customerId)
+ stmt.execute()
+ }
// Update bank info
conn.dynamicUpdate(
@@ -279,7 +331,7 @@ class AccountDAO(private val db: Database) {
sequence {
isPublic?.let { yield(it) }
debtLimit?.let { yield(it.value); yield(it.frac) }
- yield(customer_id)
+ yield(customerId)
}
)
@@ -290,6 +342,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") }
name?.let { yield("name=?") }
},
"WHERE customer_id = ?",
@@ -297,8 +350,9 @@ class AccountDAO(private val db: Database) {
cashoutPayto.some { yield(it?.canonical) }
phone.some { yield(it) }
email.some { yield(it) }
+ tan_channel.some { yield(it?.name) }
name?.let { yield(it) }
- yield(customer_id)
+ yield(customerId)
}
)
@@ -310,20 +364,29 @@ class AccountDAO(private val db: Database) {
enum class AccountPatchAuthResult {
UnknownAccount,
OldPasswordMismatch,
+ TanRequired,
Success
}
/** Change account [login] password to [newPw] if current match [oldPw] */
- suspend fun reconfigPassword(login: String, newPw: String, oldPw: String?): AccountPatchAuthResult = db.serializable {
+ suspend fun reconfigPassword(
+ login: String,
+ newPw: String,
+ oldPw: String?,
+ is2fa: Boolean
+ ): AccountPatchAuthResult = db.serializable {
it.transaction { conn ->
- val currentPwh = conn.prepareStatement("""
- SELECT password_hash FROM customers WHERE login=?
+ val (currentPwh, tanRequired) = conn.prepareStatement("""
+ SELECT password_hash, (NOT ? AND tan_channel IS NOT NULL) FROM customers WHERE login=?
""").run {
- setString(1, login)
- oneOrNull { it.getString(1) }
+ setBoolean(1, is2fa)
+ setString(2, login)
+ oneOrNull {
+ Pair(it.getString(1), it.getBoolean(2))
+ } ?: return@transaction AccountPatchAuthResult.UnknownAccount
}
- if (currentPwh == null) {
- AccountPatchAuthResult.UnknownAccount
+ if (tanRequired) {
+ AccountPatchAuthResult.TanRequired
} else if (oldPw != null && !CryptoUtil.checkpw(oldPw, currentPwh)) {
AccountPatchAuthResult.OldPasswordMismatch
} else {
@@ -378,6 +441,7 @@ class AccountDAO(private val db: Database) {
name
,email
,phone
+ ,tan_channel
,cashout_payto
,internal_payto_uri
,(balance).val AS balance_val
@@ -388,7 +452,7 @@ class AccountDAO(private val db: Database) {
,is_public
,is_taler_exchange
FROM customers
- JOIN bank_accounts
+ JOIN bank_accounts
ON customer_id=owning_customer_id
WHERE login=?
""")
@@ -400,6 +464,7 @@ class AccountDAO(private val db: Database) {
email = Option.Some(it.getString("email")),
phone = Option.Some(it.getString("phone"))
),
+ tan_channel = it.getString("tan_channel")?.run { TanChannel.valueOf(this) },
cashout_payto_uri = it.getString("cashout_payto"),
payto_uri = it.getString("internal_payto_uri"),
balance = Balance(
@@ -442,7 +507,6 @@ class AccountDAO(private val db: Database) {
) {
PublicAccount(
username = it.getString("login"),
- account_name = it.getString("login"),
payto_uri = it.getString("internal_payto_uri"),
balance = Balance(
amount = it.getAmount("balance", db.bankCurrency),
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
index bf8be4fb..a7950aa2 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -29,14 +29,14 @@ import tech.libeufin.bank.*
class CashoutDAO(private val db: Database) {
/** Result of cashout operation creation */
sealed class CashoutCreationResult {
- /** Cashout [id] has been created or refreshed. If [tanCode] is not null, use [tanInfo] to send it via [tanChannel] then call [markSent] */
- data class Success(val id: Long, val tanInfo: String, val tanCode: String?): CashoutCreationResult()
+ data class Success(val id: Long): CashoutCreationResult()
object BadConversion: CashoutCreationResult()
object AccountNotFound: CashoutCreationResult()
object AccountIsExchange: CashoutCreationResult()
- object MissingTanInfo: CashoutCreationResult()
object BalanceInsufficient: CashoutCreationResult()
object RequestUidReuse: CashoutCreationResult()
+ object NoCashoutPayto: CashoutCreationResult()
+ object TanRequired: CashoutCreationResult()
}
/** Create a new cashout operation */
@@ -46,24 +46,20 @@ class CashoutDAO(private val db: Database) {
amountDebit: TalerAmount,
amountCredit: TalerAmount,
subject: String,
- tanChannel: TanChannel,
- tanCode: String,
now: Instant,
- retryCounter: Int,
- validityPeriod: Duration
+ is2fa: Boolean
): CashoutCreationResult = db.serializable { conn ->
val stmt = conn.prepareStatement("""
SELECT
out_bad_conversion,
out_account_not_found,
out_account_is_exchange,
- out_missing_tan_info,
out_balance_insufficient,
out_request_uid_reuse,
- out_cashout_id,
- out_tan_info,
- out_tan_code
- FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?)
+ out_no_cashout_payto,
+ out_tan_required,
+ out_cashout_id
+ FROM cashout_create(?,?,(?,?)::taler_amount,(?,?)::taler_amount,?,?,?)
""")
stmt.setString(1, login)
stmt.setBytes(2, requestUid.raw)
@@ -73,10 +69,7 @@ class CashoutDAO(private val db: Database) {
stmt.setInt(6, amountCredit.frac)
stmt.setString(7, subject)
stmt.setLong(8, now.toDbMicros() ?: throw faultyTimestampByBank())
- stmt.setString(9, tanChannel.name)
- stmt.setString(10, tanCode)
- stmt.setInt(11, retryCounter)
- stmt.setLong(12, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ stmt.setBoolean(9, is2fa)
stmt.executeQuery().use {
when {
!it.next() ->
@@ -84,114 +77,11 @@ class CashoutDAO(private val db: Database) {
it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BadConversion
it.getBoolean("out_account_not_found") -> CashoutCreationResult.AccountNotFound
it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.AccountIsExchange
- it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MissingTanInfo
it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BalanceInsufficient
it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.RequestUidReuse
- else -> CashoutCreationResult.Success(
- id = it.getLong("out_cashout_id"),
- tanInfo = it.getString("out_tan_info"),
- tanCode = it.getString("out_tan_code")
- )
- }
- }
- }
-
- /** Mark cashout operation [id] challenge as having being successfully sent [now] and not to be retransmit until after [retransmissionPeriod] */
- suspend fun markSent(
- id: Long,
- now: Instant,
- retransmissionPeriod: Duration,
- tanChannel: TanChannel,
- tanInfo: String
- ) = db.serializable {
- it.transaction { conn ->
- conn.prepareStatement("""
- SELECT challenge_mark_sent(challenge, ?, ?)
- FROM cashout_operations
- WHERE cashout_id=?
- """).run {
- setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank())
- setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
- setLong(3, id)
- executeQueryCheck()
- }
- conn.prepareStatement("""
- UPDATE cashout_operations
- SET tan_channel = ?, tan_info = ?
- WHERE cashout_id=?
- """).run {
- setString(1, tanChannel.name)
- setString(2, tanInfo)
- setLong(3, id)
- executeUpdateCheck()
- }
- }
- }
-
- /** Abort cashout operation [id] owned by [login] */
- suspend fun abort(id: Long, login: String): AbortResult = db.serializable { conn ->
- val stmt = conn.prepareStatement("""
- UPDATE cashout_operations
- SET aborted = local_transaction IS NULL
- FROM bank_accounts JOIN customers ON customer_id=owning_customer_id
- WHERE cashout_id=? AND bank_account=bank_account_id AND login=?
- RETURNING local_transaction IS NOT NULL
- """)
- stmt.setLong(1, id)
- stmt.setString(2, login)
- when (stmt.oneOrNull { it.getBoolean(1) }) {
- null -> AbortResult.UnknownOperation
- true -> AbortResult.AlreadyConfirmed
- false -> AbortResult.Success
- }
- }
-
- /** Result status of cashout operation confirmation */
- enum class CashoutConfirmationResult {
- SUCCESS,
- BAD_CONVERSION,
- OP_NOT_FOUND,
- BAD_TAN_CODE,
- BALANCE_INSUFFICIENT,
- NO_RETRY,
- NO_CASHOUT_PAYTO,
- ABORTED
- }
-
- /** Confirm cashout operation [id] owned by [login] */
- suspend fun confirm(
- id: Long,
- login: String,
- tanCode: String,
- timestamp: Instant
- ): CashoutConfirmationResult = db.serializable { conn ->
- val stmt = conn.prepareStatement("""
- SELECT
- out_no_op,
- out_bad_conversion,
- out_bad_code,
- out_balance_insufficient,
- out_aborted,
- out_no_retry,
- out_no_cashout_payto
- FROM cashout_confirm(?, ?, ?, ?);
- """)
- stmt.setLong(1, id)
- stmt.setString(2, login)
- stmt.setString(3, tanCode)
- stmt.setLong(4, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
- stmt.executeQuery().use {
- when {
- !it.next() ->
- throw internalServerError("No result from DB procedure cashout_create")
- it.getBoolean("out_no_op") -> CashoutConfirmationResult.OP_NOT_FOUND
- it.getBoolean("out_bad_code") -> CashoutConfirmationResult.BAD_TAN_CODE
- it.getBoolean("out_balance_insufficient") -> CashoutConfirmationResult.BALANCE_INSUFFICIENT
- it.getBoolean("out_aborted") -> CashoutConfirmationResult.ABORTED
- it.getBoolean("out_no_retry") -> CashoutConfirmationResult.NO_RETRY
- it.getBoolean("out_no_cashout_payto") -> CashoutConfirmationResult.NO_CASHOUT_PAYTO
- it.getBoolean("out_bad_conversion") -> CashoutConfirmationResult.BAD_CONVERSION
- else -> CashoutConfirmationResult.SUCCESS
+ it.getBoolean("out_no_cashout_payto") -> CashoutCreationResult.NoCashoutPayto
+ it.getBoolean("out_tan_required") -> CashoutCreationResult.TanRequired
+ else -> CashoutCreationResult.Success(it.getLong("out_cashout_id"))
}
}
}
@@ -200,12 +90,7 @@ class CashoutDAO(private val db: Database) {
suspend fun get(id: Long, login: String): CashoutStatusResponse? = db.conn { conn ->
val stmt = conn.prepareStatement("""
SELECT
- CASE
- WHEN aborted THEN 'aborted'
- WHEN local_transaction IS NOT NULL THEN 'confirmed'
- ELSE 'pending'
- END as status
- ,(amount_debit).val as amount_debit_val
+ (amount_debit).val as amount_debit_val
,(amount_debit).frac as amount_debit_frac
,(amount_credit).val as amount_credit_val
,(amount_credit).frac as amount_credit_frac
@@ -213,7 +98,10 @@ class CashoutDAO(private val db: Database) {
,creation_time
,transaction_date as confirmation_date
,tan_channel
- ,tan_info
+ ,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
@@ -224,7 +112,7 @@ class CashoutDAO(private val db: Database) {
stmt.setString(2, login)
stmt.oneOrNull {
CashoutStatusResponse(
- status = CashoutStatus.valueOf(it.getString("status")),
+ status = CashoutStatus.confirmed,
amount_debit = it.getAmount("amount_debit", db.bankCurrency),
amount_credit = it.getAmount("amount_credit", db.fiatCurrency!!),
subject = it.getString("subject"),
@@ -245,11 +133,6 @@ class CashoutDAO(private val db: Database) {
SELECT
cashout_id
,login
- ,CASE
- WHEN aborted THEN 'aborted'
- WHEN local_transaction IS NOT NULL THEN 'confirmed'
- ELSE 'pending'
- END as status
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -258,20 +141,14 @@ class CashoutDAO(private val db: Database) {
GlobalCashoutInfo(
cashout_id = it.getLong("cashout_id"),
username = it.getString("login"),
- status = CashoutStatus.valueOf(it.getString("status"))
+ status = CashoutStatus.confirmed
)
}
/** Get a page of all cashout operations owned by [login] */
suspend fun pageForUser(params: PageParams, login: String): List<CashoutInfo> =
db.page(params, "cashout_id", """
- SELECT
- cashout_id
- ,CASE
- WHEN aborted THEN 'aborted'
- WHEN local_transaction IS NOT NULL THEN 'confirmed'
- ELSE 'pending'
- END as status
+ SELECT cashout_id
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -284,7 +161,7 @@ class CashoutDAO(private val db: Database) {
) {
CashoutInfo(
cashout_id = it.getLong("cashout_id"),
- status = CashoutStatus.valueOf(it.getString("status"))
+ status = CashoutStatus.confirmed
)
}
} \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
index 8ea2f00a..e4effe00 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
@@ -88,6 +88,7 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val
val account = AccountDAO(this)
val transaction = TransactionDAO(this)
val token = TokenDAO(this)
+ val tan = TanDAO(this)
suspend fun monitor(
params: MonitorParams
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
new file mode 100644
index 00000000..457d1216
--- /dev/null
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
@@ -0,0 +1,177 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 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.db
+
+import tech.libeufin.util.*
+import tech.libeufin.bank.*
+import tech.libeufin.bank.db.*
+import java.util.concurrent.TimeUnit
+import java.time.Duration
+import java.time.Instant
+
+/** Data access logic for tan challenged */
+class TanDAO(private val db: Database) {
+ /** Create new TAN challenge */
+ suspend fun new(
+ login: String,
+ op: Operation,
+ body: String,
+ code: String,
+ now: Instant,
+ retryCounter: Int,
+ validityPeriod: Duration,
+ channel: TanChannel? = null,
+ info: String? = null
+ ): Long = db.serializable { conn ->
+ val stmt = conn.prepareStatement("SELECT tan_challenge_create(?,?::op_enum,?,?,?,?,?,?::tan_enum,?)")
+ stmt.setString(1, body)
+ stmt.setString(2, op.name)
+ stmt.setString(3, code)
+ stmt.setLong(4, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ stmt.setInt(6, retryCounter)
+ stmt.setString(7, login)
+ stmt.setString(8, channel?.name)
+ stmt.setString(9, info)
+ stmt.oneOrNull {
+ it.getLong(1)
+ } ?: throw internalServerError("TAN challenge returned nothing.")
+ }
+
+ /** Result of TAN challenge transmission */
+ sealed class TanSendResult {
+ data class Success(val tanInfo: String, val tanChannel: TanChannel, val tanCode: String?): TanSendResult()
+ object NotFound: TanSendResult()
+ }
+
+ /** Request TAN challenge transmission */
+ suspend fun send(
+ id: Long,
+ login: String,
+ code: String,
+ now: Instant,
+ retryCounter: Int,
+ validityPeriod: Duration
+ ) = db.serializable { conn ->
+ val stmt = conn.prepareStatement("SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info FROM tan_challenge_send(?,?,?,?,?,?)")
+ stmt.setLong(1, id)
+ stmt.setString(2, login)
+ stmt.setString(3, code)
+ stmt.setLong(4, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ stmt.setInt(6, retryCounter)
+ stmt.executeQuery().use {
+ when {
+ !it.next() -> throw internalServerError("TAN send returned nothing.")
+ it.getBoolean("out_no_op") -> TanSendResult.NotFound
+ else -> TanSendResult.Success(
+ tanInfo = it.getString("out_tan_info"),
+ tanChannel = it.getString("out_tan_channel").run { TanChannel.valueOf(this) },
+ tanCode = it.getString("out_tan_code")
+ )
+ }
+ }
+ }
+
+ /** Mark TAN challenge transmission */
+ suspend fun markSent(
+ id: Long,
+ now: Instant,
+ retransmissionPeriod: Duration
+ ) = db.serializable { conn ->
+ val stmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)")
+ stmt.setLong(1, id)
+ stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
+ stmt.executeQuery()
+ }
+
+ /** Result of TAN challenge solution */
+ sealed class TanSolveResult {
+ data class Success(val body: String, 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()
+ }
+
+ /** Solve TAN challenge */
+ suspend fun solve(
+ id: Long,
+ login: String,
+ code: String,
+ now: Instant
+ ) = db.serializable { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ out_ok, out_no_op, out_no_retry, out_expired,
+ out_body, out_op, out_channel, out_info
+ FROM tan_challenge_try(?,?,?,?)""")
+ stmt.setLong(1, id)
+ stmt.setString(2, login)
+ stmt.setString(3, code)
+ stmt.setLong(4, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.executeQuery().use {
+ when {
+ !it.next() -> throw internalServerError("TAN try returned nothing")
+ it.getBoolean("out_ok") -> TanSolveResult.Success(
+ body = it.getString("out_body"),
+ op = Operation.valueOf(it.getString("out_op")),
+ channel = it.getString("out_channel")?.run { TanChannel.valueOf(this) },
+ info = it.getString("out_info")
+ )
+ it.getBoolean("out_no_op") -> TanSolveResult.NotFound
+ it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry
+ it.getBoolean("out_expired") -> TanSolveResult.Expired
+ else -> TanSolveResult.BadCode
+ }
+ }
+ }
+
+ data class Challenge (
+ val body: String,
+ val channel: TanChannel?,
+ val info: String?
+ )
+
+ /** Get a solved TAN challenge [id] for account [login] and [op] */
+ suspend fun challenge(
+ id: Long,
+ login: String,
+ op: Operation
+ ) = db.serializable { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT body, tan_challenges.tan_channel, tan_info
+ FROM tan_challenges
+ JOIN customers ON customer=customer_id
+ WHERE challenge_id=? AND op=?::op_enum AND login=?
+ """)
+ stmt.setLong(1, id)
+ stmt.setString(2, op.name)
+ stmt.setString(3, login)
+ stmt.oneOrNull {
+ Challenge(
+ body = it.getString("body"),
+ channel = it.getString("tan_channel")?.run { TanChannel.valueOf(this) },
+ info = it.getString("tan_info")
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
index 1485ec52..a72f9743 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
@@ -38,6 +38,7 @@ class TransactionDAO(private val db: Database) {
object UnknownDebtor: BankTransactionResult()
object BothPartySame: BankTransactionResult()
object BalanceInsufficient: BankTransactionResult()
+ object TanRequired: BankTransactionResult()
}
/** Create a new transaction */
@@ -47,6 +48,7 @@ class TransactionDAO(private val db: Database) {
subject: String,
amount: TalerAmount,
timestamp: Instant,
+ is2fa: Boolean
): BankTransactionResult = db.serializable { conn ->
val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank();
conn.transaction {
@@ -56,6 +58,7 @@ class TransactionDAO(private val db: Database) {
,out_debtor_not_found
,out_same_account
,out_balance_insufficient
+ ,out_tan_required
,out_credit_bank_account_id
,out_debit_bank_account_id
,out_credit_row_id
@@ -63,7 +66,7 @@ class TransactionDAO(private val db: Database) {
,out_creditor_is_exchange
,out_debtor_is_exchange
,out_creditor_admin
- FROM bank_transaction(?,?,?,(?,?)::taler_amount,?)
+ FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?)
"""
)
stmt.setString(1, creditAccountPayto.canonical)
@@ -72,6 +75,7 @@ class TransactionDAO(private val db: Database) {
stmt.setLong(4, amount.value)
stmt.setInt(5, amount.frac)
stmt.setLong(6, now)
+ stmt.setBoolean(7, is2fa)
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("Bank transaction didn't properly return")
@@ -80,6 +84,7 @@ class TransactionDAO(private val db: Database) {
it.getBoolean("out_same_account") -> BankTransactionResult.BothPartySame
it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BalanceInsufficient
it.getBoolean("out_creditor_admin") -> BankTransactionResult.AdminCreditor
+ it.getBoolean("out_tan_required") -> BankTransactionResult.TanRequired
else -> {
val creditAccountId = it.getLong("out_credit_bank_account_id")
val creditRowId = it.getLong("out_credit_row_id")
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
index 4da52776..380263b4 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
@@ -42,19 +42,21 @@ class WithdrawalDAO(private val db: Database) {
suspend fun create(
login: String,
uuid: UUID,
- amount: TalerAmount
+ amount: TalerAmount,
+ now: Instant
): WithdrawalCreationResult = db.serializable { conn ->
val stmt = conn.prepareStatement("""
SELECT
out_account_not_found,
out_account_is_exchange,
out_balance_insufficient
- FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount);
+ FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount, ?);
""")
stmt.setString(1, login)
stmt.setObject(2, uuid)
stmt.setLong(3, amount.value)
stmt.setInt(4, amount.frac)
+ stmt.setLong(5, now.toDbMicros() ?: throw faultyTimestampByBank())
stmt.executeQuery().use {
when {
!it.next() ->
@@ -69,7 +71,6 @@ class WithdrawalDAO(private val db: Database) {
/** Abort withdrawal operation [uuid] */
suspend fun abort(uuid: UUID): AbortResult = db.serializable { conn ->
- // TODO login check
val stmt = conn.prepareStatement("""
SELECT
out_no_op,
@@ -140,13 +141,16 @@ class WithdrawalDAO(private val db: Database) {
UnknownExchange,
BalanceInsufficient,
NotSelected,
- AlreadyAborted
+ AlreadyAborted,
+ TanRequired
}
/** Confirm withdrawal operation [uuid] */
suspend fun confirm(
+ login: String,
uuid: UUID,
- now: Instant
+ now: Instant,
+ is2fa: Boolean
): WithdrawalConfirmationResult = db.serializable { conn ->
// TODO login check
val stmt = conn.prepareStatement("""
@@ -155,12 +159,15 @@ class WithdrawalDAO(private val db: Database) {
out_exchange_not_found,
out_balance_insufficient,
out_not_selected,
- out_aborted
- FROM confirm_taler_withdrawal(?, ?);
+ out_aborted,
+ out_tan_required
+ FROM confirm_taler_withdrawal(?,?,?,?);
"""
)
- stmt.setObject(1, uuid)
- stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setString(1, login)
+ stmt.setObject(2, uuid)
+ stmt.setLong(3, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setBoolean(4, is2fa)
stmt.executeQuery().use {
when {
!it.next() ->
@@ -170,6 +177,7 @@ class WithdrawalDAO(private val db: Database) {
it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient
it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected
it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted
+ it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired
else -> WithdrawalConfirmationResult.Success
}
}
@@ -252,10 +260,7 @@ class WithdrawalDAO(private val db: Database) {
amount = it.getAmount("amount", db.bankCurrency),
username = it.getString("login"),
selected_exchange_account = it.getString("selected_exchange_payto"),
- selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey),
- selection_done = it.getBoolean("selection_done"),
- confirmation_done = it.getBoolean("confirmation_done"),
- aborted = it.getBoolean("aborted"),
+ selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey)
)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index 85dd8ff2..84dc96a6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -29,6 +29,9 @@ import io.ktor.server.routing.RouteSelectorEvaluation
import io.ktor.server.routing.RoutingResolveContext
import io.ktor.server.util.*
import io.ktor.util.pipeline.PipelineContext
+import kotlinx.serialization.*
+import kotlinx.serialization.descriptors.*
+import kotlinx.serialization.encoding.*
import java.net.*
import java.time.*
import java.time.temporal.*
@@ -132,7 +135,11 @@ suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig, pw: String? =
isTalerExchange = false,
maxDebt = ctx.defaultDebtLimit,
bonus = TalerAmount(0, 0, ctx.regionalCurrency),
- checkPaytoIdempotent = false
+ checkPaytoIdempotent = false,
+ email = null,
+ phone = null,
+ cashoutPayto = null,
+ tanChannel = null
)
}
@@ -157,4 +164,19 @@ fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route =
}
}
- \ No newline at end of file
+@Serializable(with = StoredUUID.Serializer::class)
+data class StoredUUID(val value: UUID) {
+ internal object Serializer : KSerializer<StoredUUID> {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("StoredUUID", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: StoredUUID) {
+ encoder.encodeString(value.value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): StoredUUID {
+ val string = decoder.decodeString()
+ return StoredUUID(UUID.fromString(string))
+ }
+ }
+}
diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt
index dbc920ef..43b6fbef 100644
--- a/bank/src/test/kotlin/AmountTest.kt
+++ b/bank/src/test/kotlin/AmountTest.kt
@@ -56,6 +56,7 @@ class AmountTest {
subject = "test",
amount = due,
timestamp = Instant.now(),
+ is2fa = false
)
val txBool = when (txRes) {
BankTransactionResult.BalanceInsufficient -> false
@@ -69,6 +70,7 @@ class AmountTest {
login = "merchant",
uuid = UUID.randomUUID(),
amount = due,
+ now = Instant.now()
)
val wBool = when (wRes) {
WithdrawalCreationResult.BalanceInsufficient -> false
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
index dd7cce4e..ce6b46c7 100644
--- a/bank/src/test/kotlin/BankIntegrationApiTest.kt
+++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -143,7 +143,6 @@ class BankIntegrationApiTest {
// POST /taler-integration/withdrawal-operation/UUID/abort
@Test
fun abort() = bankSetup { _ ->
- // TODO auth routine
// Check abort created
client.postA("/accounts/merchant/withdrawals") {
json { "amount" to "KUDOS:1" }
@@ -183,7 +182,7 @@ class BankIntegrationApiTest {
}
// Check bad UUID
- client.postA("/taler-integration/withdrawal-operation//chocolate/abort").assertBadRequest()
+ client.postA("/taler-integration/withdrawal-operation/chocolate/abort").assertBadRequest()
// Check unknown
client.postA("/taler-integration/withdrawal-operation/${UUID.randomUUID()}/abort")
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
index ca538da4..86115e78 100644
--- a/bank/src/test/kotlin/CoreBankApiTest.kt
+++ b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -183,13 +183,13 @@ class CoreBankAccountsApiTest {
// Check idempotency with payto
client.post("/accounts") {
json(req) {
- "internal_payto_uri" to payto
+ "payto_uri" to payto
}
}.assertOk()
// Check payto conflict
client.post("/accounts") {
json(req) {
- "internal_payto_uri" to genIbanPaytoUri()
+ "payto_uri" to genIbanPaytoUri()
}
}.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE)
}
@@ -201,7 +201,7 @@ class CoreBankAccountsApiTest {
"password" to "password"
"name" to "Jane"
"is_public" to true
- "internal_payto_uri" to ibanPayto
+ "payto_uri" to ibanPayto
"is_taler_exchange" to true
}
// Check Ok
@@ -215,7 +215,7 @@ class CoreBankAccountsApiTest {
json(req)
}.assertOk()
- // Check debit_threshold
+ // Check admin only debit_threshold
obj {
"username" to "bat"
"password" to "password"
@@ -231,6 +231,38 @@ class CoreBankAccountsApiTest {
}.assertOk()
}
+ // Check admin only tan_channel
+ obj {
+ "username" to "bat2"
+ "password" to "password"
+ "name" to "Bat"
+ "contact_data" to obj {
+ "phone" to "+456"
+ }
+ "tan_channel" to "sms"
+ }.let { req ->
+ client.post("/accounts") {
+ json(req)
+ }.assertErr(TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL)
+ client.post("/accounts") {
+ json(req)
+ pwAuth("admin")
+ }.assertOk()
+ }
+
+ // Check tan info
+ for (channel in listOf("sms", "email")) {
+ client.post("/accounts") {
+ pwAuth("admin")
+ json {
+ "username" to "bat2"
+ "password" to "password"
+ "name" to "Bat"
+ "tan_channel" to channel
+ }
+ }.assertErr(TalerErrorCode.BANK_MISSING_TAN_INFO)
+ }
+
// Reserved account
RESERVED_ACCOUNTS.forEach {
client.post("/accounts") {
@@ -317,13 +349,24 @@ class CoreBankAccountsApiTest {
}.assertOk()
}
- // DELETE /accounts/USERNAME
+ // Test admin-only account creation
@Test
- fun delete() = bankSetup { _ ->
- // Unknown account
- client.delete("/accounts/unknown") {
+ fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { _ ->
+ client.post("/accounts") {
pwAuth("admin")
- }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+ json {
+ "username" to "baz"
+ "password" to "xyz"
+ "name" to "Mallory"
+ "tan_channel" to "email"
+ }
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
+ }
+
+ // DELETE /accounts/USERNAME
+ @Test
+ fun delete() = bankSetup { db ->
+ authRoutine(HttpMethod.Delete, "/accounts/merchant", allowAdmin = true)
// Reserved account
RESERVED_ACCOUNTS.forEach {
@@ -333,31 +376,42 @@ class CoreBankAccountsApiTest {
}
client.deleteA("/accounts/exchange")
.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)
-
- // successful deletion
+
client.post("/accounts") {
json {
"username" to "john"
- "password" to "password"
- "name" to "John Smith"
+ "password" to "john-password"
+ "name" to "John"
+ "payto_uri" to genTmpPayTo()
}
}.assertOk()
- client.delete("/accounts/john") {
- pwAuth("admin")
- }.assertNoContent()
- // Trying again must yield 404
+ fillTanInfo("john")
+ // Fail to delete, due to a non-zero balance.
+ tx("customer", "KUDOS:1", "john")
+ client.deleteA("/accounts/john")
+ .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
+ // Sucessful deletion
+ tx("john", "KUDOS:1", "customer")
+ // TODO remove with gc
+ db.conn { conn ->
+ val id = conn.prepareStatement("SELECT bank_account_id FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE login = ?").run {
+ setString(1, "john")
+ oneOrNull {
+ it.getLong(1)
+ }!!
+ }
+ conn.prepareStatement("DELETE FROM bank_account_transactions WHERE bank_account_id=?").run {
+ setLong(1, id)
+ execute()
+ }
+ }
+ client.deleteA("/accounts/john")
+ .assertChallenge()
+ .assertNoContent()
+ // Account no longer exists
client.delete("/accounts/john") {
pwAuth("admin")
}.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
-
-
- // fail to delete, due to a non-zero balance.
- tx("customer", "KUDOS:1", "merchant")
- client.deleteA("/accounts/merchant")
- .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
- tx("merchant", "KUDOS:1", "customer")
- client.deleteA("/accounts/merchant")
- .assertNoContent()
}
// Test admin-only account deletion
@@ -399,35 +453,39 @@ class CoreBankAccountsApiTest {
// PATCH /accounts/USERNAME
@Test
fun reconfig() = bankSetup { _ ->
- authRoutine(HttpMethod.Patch, "/accounts/merchant", withAdmin = true)
+ authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true)
+
+ // Check tan info
+ for (channel in listOf("sms", "email")) {
+ client.patchA("/accounts/merchant") {
+ json { "tan_channel" to channel }
+ }.assertErr(TalerErrorCode.BANK_MISSING_TAN_INFO)
+ }
- // Successful attempt now.
+ // Successful attempt now
val cashout = IbanPayTo(genIbanPaytoUri())
val req = obj {
"cashout_payto_uri" to cashout.canonical
"name" to "Roger"
- "is_public" to true
+ "is_public" to true
+ "contact_data" to obj {
+ "phone" to "+99"
+ "email" to "foo@example.com"
+ }
}
client.patchA("/accounts/merchant") {
json(req)
}.assertNoContent()
- // Checking idempotence.
+ // Checking idempotence
client.patchA("/accounts/merchant") {
json(req)
}.assertNoContent()
+
checkAdminOnly(
obj(req) { "debit_threshold" to "KUDOS:100" },
TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
)
- checkAdminOnly(
- obj(req) { "contact_data" to obj { "phone" to "+99" } },
- TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
- )
- checkAdminOnly(
- obj(req) { "contact_data" to obj { "email" to "foo@example.com" } },
- TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
- )
// Check currency
client.patch("/accounts/merchant") {
@@ -466,6 +524,19 @@ class CoreBankAccountsApiTest {
"is_public" to true
}
}.assertConflict(TalerErrorCode.END)
+
+ // Check 2FA
+ fillTanInfo("merchant")
+ client.patchA("/accounts/merchant") {
+ json { "is_public" to false }
+ }.assertChallenge { _, _ ->
+ client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
+ assert(obj.is_public)
+ }
+ }.assertNoContent();
+ client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
+ assert(!obj.is_public)
+ }
}
// Test admin-only account patch
@@ -492,10 +563,21 @@ class CoreBankAccountsApiTest {
}
}
+ // Test TAN check account patch
+ @Test
+ fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { _ ->
+ // Check unsupported TAN channel
+ client.patchA("/accounts/customer") {
+ json {
+ "tan_channel" to "email"
+ }
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
+ }
+
// PATCH /accounts/USERNAME/auth
@Test
fun passwordChange() = bankSetup { _ ->
- authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", withAdmin = true)
+ authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = true)
// Changing the password.
client.patch("/accounts/customer/auth") {
@@ -540,6 +622,21 @@ class CoreBankAccountsApiTest {
client.patch("/accounts/customer/auth") {
pwAuth("admin")
json {
+ "new_password" to "customer-password"
+ }
+ }.assertNoContent()
+
+ // Check 2FA
+ fillTanInfo("customer")
+ client.patchA("/accounts/customer/auth") {
+ json {
+ "old_password" to "customer-password"
+ "new_password" to "it-password"
+ }
+ }.assertChallenge().assertNoContent()
+ client.patch("/accounts/customer/auth") {
+ pwAuth("admin")
+ json {
"new_password" to "new-password"
}
}.assertNoContent()
@@ -578,7 +675,7 @@ class CoreBankAccountsApiTest {
val obj = json<PublicAccountsResponse>()
assertEquals(3, obj.public_accounts.size)
obj.public_accounts.forEach {
- assertEquals(0, it.account_name.toInt() % 2)
+ assertEquals(0, it.username.toInt() % 2)
}
}
// All accounts
@@ -610,7 +707,7 @@ class CoreBankAccountsApiTest {
// GET /accounts/USERNAME
@Test
fun get() = bankSetup { _ ->
- authRoutine(HttpMethod.Get, "/accounts/merchant", withAdmin = true)
+ authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true)
// Check ok
client.getA("/accounts/merchant").assertOkJson<AccountData> {
assertEquals("Merchant", it.name)
@@ -770,8 +867,7 @@ class CoreBankTransactionsApiTest {
repeat(2) {
tx("merchant", "KUDOS:3", "customer")
}
- client.post("/accounts/merchant/transactions") {
- pwAuth("merchant")
+ client.postA("/accounts/merchant/transactions") {
json {
"payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5"
}
@@ -789,9 +885,9 @@ class CoreBankTransactionsApiTest {
assertBalance("exchange", "+KUDOS:0")
tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction
tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction
- val reserve_pub = randEddsaPublicKey();
- tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reserve_pub)) // Accept incoming
- tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reserve_pub)) // Bounce reserve_pub reuse
+ val reservePub = randEddsaPublicKey();
+ tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Accept incoming
+ tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Bounce reserve_pub reuse
assertBalance("merchant", "-KUDOS:1")
assertBalance("exchange", "+KUDOS:1")
@@ -806,6 +902,22 @@ class CoreBankTransactionsApiTest {
tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Warn wtid reuse
assertBalance("merchant", "+KUDOS:3")
assertBalance("exchange", "-KUDOS:3")
+
+ // Check 2fa
+ fillTanInfo("merchant")
+ assertBalance("merchant", "+KUDOS:3")
+ assertBalance("customer", "+KUDOS:0")
+ client.postA("/accounts/merchant/transactions") {
+ json {
+ "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1"
+ }
+ }.assertChallenge { _,_->
+ assertBalance("merchant", "+KUDOS:3")
+ assertBalance("customer", "+KUDOS:0")
+ }.assertOkJson <TransactionCreateResponse> {
+ assertBalance("merchant", "+KUDOS:2")
+ assertBalance("customer", "+KUDOS:1")
+ }
}
}
@@ -842,11 +954,7 @@ class CoreBankWithdrawalApiTest {
client.get("/withdrawals/${it.withdrawal_id}") {
pwAuth("merchant")
}.assertOkJson<WithdrawalPublicInfo> {
- assert(!it.selection_done)
- assert(!it.aborted)
- assert(!it.confirmation_done)
assertEquals(amount, it.amount)
- // TODO check all status
}
}
@@ -864,7 +972,7 @@ class CoreBankWithdrawalApiTest {
// POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm
@Test
fun confirm() = bankSetup { _ ->
- // TODO auth routine
+ authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/confirm")
// Check confirm created
client.postA("/accounts/merchant/withdrawals") {
json { "amount" to "KUDOS:1" }
@@ -895,7 +1003,7 @@ class CoreBankWithdrawalApiTest {
}.assertOkJson<BankAccountCreateWithdrawalResponse> {
val uuid = it.taler_withdraw_uri.split("/").last()
withdrawalSelect(uuid)
- client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
+ client.postA("/taler-integration/withdrawal-operation/$uuid/abort").assertNoContent()
// Check error
client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
@@ -915,7 +1023,19 @@ class CoreBankWithdrawalApiTest {
.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
// Check can abort because not confirmed
- client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
+ client.postA("/taler-integration/withdrawal-operation/$uuid/abort").assertNoContent()
+ }
+
+ // Check confirm another user's operation
+ client.postA("/accounts/customer/withdrawals") {
+ json { "amount" to "KUDOS:1" }
+ }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+ val uuid = it.taler_withdraw_uri.split("/").last()
+ withdrawalSelect(uuid)
+
+ // Check error
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
+ .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}
// Check bad UUID
@@ -924,6 +1044,21 @@ class CoreBankWithdrawalApiTest {
// Check unknown
client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm")
.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
+
+ // Check 2fa
+ fillTanInfo("merchant")
+ assertBalance("merchant", "-KUDOS:6")
+ client.postA("/accounts/merchant/withdrawals") {
+ json { "amount" to "KUDOS:1" }
+ }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+ val uuid = it.taler_withdraw_uri.split("/").last()
+ withdrawalSelect(uuid)
+
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
+ .assertChallenge { _,_->
+ assertBalance("merchant", "-KUDOS:6")
+ }.assertNoContent()
+ }
}
}
@@ -940,40 +1075,17 @@ class CoreBankCashoutApiTest {
"amount_credit" to convert("KUDOS:1")
}
- // Check missing TAN info
+ // Missing info
client.postA("/accounts/customer/cashouts") {
json(req)
- }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
- client.patch("/accounts/customer") {
- pwAuth("admin")
- json {
- "contact_data" to obj {
- "phone" to "+99"
- "email" to "foo@example.com"
- }
- }
- }.assertNoContent()
+ }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
- // Check email TAN error
- client.postA("/accounts/customer/cashouts") {
- json(req) {
- "tan_channel" to "email"
- }
- }.assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)
+ fillCashoutInfo("customer")
// Check OK
client.postA("/accounts/customer/cashouts") {
json(req)
- }.assertOkJson<CashoutPending> { first ->
- smsCode("+99")
- // Check idempotency
- client.postA("/accounts/customer/cashouts") {
- json(req)
- }.assertOkJson<CashoutPending> { second ->
- assertEquals(first.cashout_id, second.cashout_id)
- assertNull(smsCode("+99"))
- }
- }
+ }.assertOkJson<CashoutResponse>()
// Trigger conflict due to reused request_uid
client.postA("/accounts/customer/cashouts") {
@@ -991,6 +1103,7 @@ class CoreBankCashoutApiTest {
// Check insufficient fund
client.postA("/accounts/customer/cashouts") {
json(req) {
+ "request_uid" to randShortHashCode()
"amount_debit" to "KUDOS:75"
"amount_credit" to convert("KUDOS:75")
}
@@ -1014,211 +1127,19 @@ class CoreBankCashoutApiTest {
"amount_credit" to "KUDOS:1"
}
}.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
- }
-
- // POST /accounts/{USERNAME}/cashouts
- @Test
- fun createNoTan() = bankSetup("test_no_tan.conf") { _ ->
- val req = obj {
- "request_uid" to randShortHashCode()
- "amount_debit" to "KUDOS:1"
- "amount_credit" to convert("KUDOS:1")
- }
-
- fillCashoutInfo("customer")
-
- // Check unsupported TAN channel
- client.postA("/accounts/customer/cashouts") {
- json(req)
- }.assertStatus(HttpStatusCode.NotImplemented, TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
- }
-
- // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort
- @Test
- fun abort() = bankSetup { _ ->
- authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/abort")
-
- fillCashoutInfo("customer")
-
- val req = obj {
- "request_uid" to randShortHashCode()
- "amount_debit" to "KUDOS:1"
- "amount_credit" to convert("KUDOS:1")
- }
-
- // Check abort created
- client.postA("/accounts/customer/cashouts") {
- json(req)
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check OK
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- // Check idempotence
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- }
- // Check abort confirmed
+ // Check 2fa
+ fillTanInfo("customer")
+ assertBalance("customer", "-KUDOS:1")
client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to smsCode("+99") }
- }.assertNoContent()
-
- // Check error
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT)
- }
-
- // Check bad id
- client.postA("/accounts/customer/cashouts/chocolate/abort") {
- json { "tan" to "code" }
- }.assertBadRequest()
-
- // Check unknown
- client.postA("/accounts/customer/cashouts/42/abort") {
- json { "tan" to "code" }
- }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
-
- // Check abort another user's operation
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check error
- client.postA("/accounts/merchant/cashouts/$id/abort")
- .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
- }
- }
-
- // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm
- @Test
- fun confirm() = bankSetup { _ ->
- authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/confirm")
-
- client.patch("/accounts/customer") {
- pwAuth("admin")
- json {
- "contact_data" to obj {
- "phone" to "+99"
- }
- }
- }.assertNoContent()
-
- val req = obj {
- "request_uid" to randShortHashCode()
- "amount_debit" to "KUDOS:1"
- "amount_credit" to convert("KUDOS:1")
- }
-
- // Check confirm
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check missing cashout address
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to "code" }
- }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
- fillCashoutInfo("customer")
-
- // Check bad TAN code
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to "nice-try" }
- }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
-
- val code = smsCode("+99")
-
- // Check OK
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to code }
- }.assertNoContent()
- // Check idempotence
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to code }
- }.assertNoContent()
- }
-
- // Check confirm another user's operation
- client.postA("/accounts/customer/cashouts") {
- json(req) {
- "request_uid" to randShortHashCode()
- "amount_credit" to convert("KUDOS:1")
- }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check error
- client.postA("/accounts/merchant/cashouts/$id/confirm") {
- json { "tan" to "unused" }
- }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
- }
-
- // Check bad conversion
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
- client.post("/conversion-info/conversion-rate") {
- pwAuth("admin")
- json {
- "cashin_ratio" to "1"
- "cashin_fee" to "KUDOS:0.1"
- "cashin_tiny_amount" to "KUDOS:0.0001"
- "cashin_rounding_mode" to "nearest"
- "cashin_min_amount" to "EUR:0.0001"
- "cashout_ratio" to "1"
- "cashout_fee" to "EUR:0.1"
- "cashout_tiny_amount" to "EUR:0.0001"
- "cashout_rounding_mode" to "nearest"
- "cashout_min_amount" to "KUDOS:0.0001"
- }
- }.assertNoContent()
-
- client.postA("/accounts/customer/cashouts/$id/confirm"){
- json { "tan" to smsCode("+99") }
- }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION)
-
- // Check can abort because not confirmed
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- }
-
- // Check balance insufficient
- client.postA("/accounts/customer/cashouts") {
- json(req) {
+ json(req) {
"request_uid" to randShortHashCode()
- "amount_credit" to convert("KUDOS:1")
}
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
- // Send too much money
- tx("customer", "KUDOS:9", "merchant")
- client.postA("/accounts/customer/cashouts/$id/confirm"){
- json { "tan" to smsCode("+99") }
- }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
-
- // Check can abort because not confirmed
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
+ }.assertChallenge { _,_->
+ assertBalance("customer", "-KUDOS:1")
+ }.assertOkJson<CashoutResponse> {
+ assertBalance("customer", "-KUDOS:2")
}
-
- // Check bad UUID
- client.postA("/accounts/customer/cashouts/chocolate/confirm") {
- json { "tan" to "code" }
- }.assertBadRequest()
-
- // Check unknown
- client.postA("/accounts/customer/cashouts/42/confirm") {
- json { "tan" to "code" }
- }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}
// GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID}
@@ -1237,41 +1158,15 @@ class CoreBankCashoutApiTest {
// Check confirm
client.postA("/accounts/customer/cashouts") {
json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
+ }.assertOkJson<CashoutResponse> {
val id = it.cashout_id
client.getA("/accounts/customer/cashouts/$id")
.assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.pending, it.status)
+ assertEquals(CashoutStatus.confirmed, it.status)
assertEquals(amountDebit, it.amount_debit)
assertEquals(amountCredit, it.amount_credit)
- assertEquals(TanChannel.sms, it.tan_channel)
- assertEquals("+99", it.tan_info)
- }
-
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to smsCode("+99") }
- }.assertNoContent()
- client.getA("/accounts/customer/cashouts/$id")
- .assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.confirmed, it.status)
- }
- }
-
- // Check abort
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
- client.getA("/accounts/customer/cashouts/$id")
- .assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.pending, it.status)
- }
-
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- client.getA("/accounts/customer/cashouts/$id")
- .assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.aborted, it.status)
+ assertNull(it.tan_channel)
+ assertNull(it.tan_info)
}
}
@@ -1286,7 +1181,7 @@ class CoreBankCashoutApiTest {
// Check get another user's operation
client.postA("/accounts/customer/cashouts") {
json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
+ }.assertOkJson<CashoutResponse> {
val id = it.cashout_id
// Check error
@@ -1325,4 +1220,227 @@ class CoreBankCashoutApiTest {
client.get("/accounts/customer/cashouts")
.assertNotImplemented()
}
+}
+
+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.expectTransmission(channel: TanChannel, info: String) {
+ this.assertOkJson<TanTransmission> {
+ assertEquals(it.tan_channel, channel)
+ assertEquals(it.tan_info, info)
+ }
+ }
+
+ // Set up 2fa
+ client.patchA("/accounts/merchant") {
+ json {
+ "contact_data" to obj {
+ "phone" to "+99"
+ "email" to "email@example.com"
+ }
+ "tan_channel" to "sms"
+ }
+ }.expectChallenge(TanChannel.sms, "+99")
+ .assertNoContent()
+
+ // Update 2fa settings - first 2FA challenge then new tan channel check
+ client.patchA("/accounts/merchant") {
+ json { // Info change
+ "contact_data" to obj { "phone" to "+98" }
+ }
+ }.expectChallenge(TanChannel.sms, "+99")
+ .expectChallenge(TanChannel.sms, "+98")
+ .assertNoContent()
+ client.patchA("/accounts/merchant") {
+ json { // Channel change
+ "tan_channel" to "email"
+ }
+ }.expectChallenge(TanChannel.sms, "+98")
+ .expectChallenge(TanChannel.email, "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")
+ .assertNoContent()
+
+ // Disable 2fa
+ client.patchA("/accounts/merchant") {
+ json { "tan_channel" to null as String? }
+ }.expectChallenge(TanChannel.sms, "+97")
+ .assertNoContent()
+
+ // Admin has no 2FA
+ client.patch("/accounts/merchant") {
+ pwAuth("admin")
+ json {
+ "contact_data" to obj { "phone" to "+99" }
+ "tan_channel" to "sms"
+ }
+ }.assertNoContent()
+ client.patch("/accounts/merchant") {
+ pwAuth("admin")
+ json { "tan_channel" to "email" }
+ }.assertNoContent()
+ client.patch("/accounts/merchant") {
+ pwAuth("admin")
+ json { "tan_channel" to null as String? }
+ }.assertNoContent()
+
+ // Check retry and invalidate
+ client.patchA("/accounts/merchant") {
+ json {
+ "contact_data" to obj { "phone" to "+88" }
+ "tan_channel" to "sms"
+ }
+ }.assertChallenge().assertNoContent()
+ client.patchA("/accounts/merchant") {
+ json { "is_public" to false }
+ }.assertAcceptedJson<TanChallenge> {
+ // Check ok
+ client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+ .expectTransmission(TanChannel.sms, "+88")
+ assertNotNull(tanCode("+88"))
+ // Check retry
+ client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+ .expectTransmission(TanChannel.sms, "+88")
+ assertNull(tanCode("+88"))
+ // Idempotent patch does nothing
+ client.patchA("/accounts/merchant") {
+ json {
+ "contact_data" to obj { "phone" to "+88" }
+ "tan_channel" to "sms"
+ }
+ }
+ client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+ .expectTransmission(TanChannel.sms, "+88")
+ 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")
+ .assertNoContent()
+ // Check invalidated
+ client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+ .expectTransmission(TanChannel.email, "email@example.com")
+ assertNotNull(tanCode("email@example.com"))
+ }
+
+ // Unknown challenge
+ client.postA("/accounts/merchant/challenge/42")
+ .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
+ }
+
+ // POST /accounts/{USERNAME}/challenge/{challenge_id}
+ @Test
+ fun sendTanErr() = bankSetup("test_tan_err.conf") { _ ->
+ // Check fail
+ client.patch("/accounts/merchant") {
+ pwAuth("admin")
+ json {
+ "contact_data" to obj { "phone" to "+1234" }
+ "tan_channel" to "sms"
+ }
+ }.assertNoContent()
+ client.patchA("/accounts/merchant") {
+ json { "is_public" to false }
+ }.assertAcceptedJson<TanChallenge> {
+ client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+ .assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)
+ }
+ }
+
+ // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm
+ @Test
+ fun confirm() = bankSetup { _ ->
+ authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm")
+
+ 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)
+
+ // Check bad TAN code
+ client.postA("/accounts/merchant/challenge/$id/confirm") {
+ json { "tan" to "nice-try" }
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
+
+ // Check wrong account
+ client.postA("/accounts/customer/challenge/$id/confirm") {
+ json { "tan" to "nice-try" }
+ }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
+
+ // Check OK
+ client.postA("/accounts/merchant/challenge/$id/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+ // Check idempotence
+ client.postA("/accounts/merchant/challenge/$id/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+
+ // Unknown challenge
+ client.postA("/accounts/merchant/challenge/42/confirm") {
+ json { "tan" to code }
+ }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
+ }
+
+ // 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
+
+ // Check invalidated
+ fillTanInfo("merchant")
+ client.postA("/accounts/merchant/challenge/$id/confirm") {
+ json { "tan" to tanCode(info) }
+ }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)
+
+ val new = client.postA("/accounts/merchant/challenge/$id")
+ .assertOkJson<TanTransmission>().tan_info
+ val code = tanCode(new)
+ // Idempotent patch does nothing
+ client.patchA("/accounts/merchant") {
+ json {
+ "contact_data" to obj { "phone" to "+88" }
+ "tan_channel" to "sms"
+ }
+ }
+ client.postA("/accounts/merchant/challenge/$id/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+
+ // Solved challenge remain solved
+ fillTanInfo("merchant")
+ client.postA("/accounts/merchant/challenge/$id/confirm") {
+ json { "tan" to code }
+ }.assertNoContent()
+ }
+ }
} \ No newline at end of file
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
index 81c4c813..fc19d5a8 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -69,11 +69,11 @@ class DatabaseTest {
}
@Test
- fun challenge() = setup { db, _ -> db.conn { conn ->
- val createStmt = conn.prepareStatement("SELECT challenge_create(?,?,?,?)")
- val sendStmt = conn.prepareStatement("SELECT challenge_mark_sent(?,?,?)")
- val tryStmt = conn.prepareStatement("SELECT ok, no_retry FROM challenge_try(?,?,?)")
- val resendStmt = conn.prepareStatement("SELECT challenge_resend(?,?,?,?,?)")
+ fun tanChallenge() = bankSetup { db -> db.conn { conn ->
+ val createStmt = conn.prepareStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)")
+ val markSentStmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)")
+ val tryStmt = conn.prepareStatement("SELECT out_ok, out_no_retry, out_expired FROM tan_challenge_try(?,'customer',?,?)")
+ val sendStmt = conn.prepareStatement("SELECT out_tan_code FROM tan_challenge_send(?,'customer',?,?,?,?)")
val validityPeriod = Duration.ofHours(1)
val retransmissionPeriod: Duration = Duration.ofMinutes(1)
@@ -87,29 +87,31 @@ class DatabaseTest {
return createStmt.oneOrNull { it.getLong(1) }!!
}
- fun send(id: Long, now: Instant) {
- sendStmt.setLong(1, id)
- sendStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now))
- sendStmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
- return sendStmt.oneOrNull { }!!
+ fun markSent(id: Long, now: Instant) {
+ markSentStmt.setLong(1, id)
+ markSentStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+ markSentStmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
+ return markSentStmt.oneOrNull { }!!
}
- fun cTry(id: Long, code: String, now: Instant): Pair<Boolean, Boolean> {
+ fun cTry(id: Long, code: String, now: Instant): Triple<Boolean, Boolean, Boolean> {
tryStmt.setLong(1, id)
tryStmt.setString(2, code)
tryStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
return tryStmt.oneOrNull {
- Pair(it.getBoolean(1), it.getBoolean(2))
+ Triple(it.getBoolean(1), it.getBoolean(2), it.getBoolean(3))
}!!
}
- fun resend(id: Long, code: String, now: Instant): String? {
- resendStmt.setLong(1, id)
- resendStmt.setString(2, code)
- resendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
- resendStmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod))
- resendStmt.setInt(5, retryCounter)
- return resendStmt.oneOrNull { it.getString(1) }
+ fun send(id: Long, code: String, now: Instant): String? {
+ sendStmt.setLong(1, id)
+ sendStmt.setString(2, code)
+ sendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+ sendStmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ sendStmt.setInt(5, retryCounter)
+ return sendStmt.oneOrNull {
+ it.getString(1)
+ }
}
val now = Instant.now()
@@ -119,50 +121,51 @@ class DatabaseTest {
// Check basic
create("good-code", now).run {
// Bad code
- assertEquals(Pair(false, false), cTry(this, "bad-code", now))
+ assertEquals(Triple(false, false, false), cTry(this, "bad-code", now))
// Good code
- assertEquals(Pair(true, false), cTry(this, "good-code", now))
+ assertEquals(Triple(true, false, false), cTry(this, "good-code", now))
// Never resend a confirmed challenge
- assertNull(resend(this, "new-code", expired))
+ assertNull(send(this, "new-code", expired))
// Confirmed challenge always ok
- assertEquals(Pair(true, false), cTry(this, "good-code", now))
+ assertEquals(Triple(true, false, false), cTry(this, "good-code", now))
}
// Check retry
create("good-code", now).run {
- send(this, now)
+ markSent(this, now)
// Bad code
- repeat(retryCounter) {
- assertEquals(Pair(false, false), cTry(this, "bad-code", now))
+ repeat(retryCounter-1) {
+ assertEquals(Triple(false, false, false), cTry(this, "bad-code", now))
}
+ assertEquals(Triple(false, true, false), cTry(this, "bad-code", now))
// Good code fail
- assertEquals(Pair(false, true), cTry(this, "good-code", now))
+ assertEquals(Triple(false, true, false), cTry(this, "good-code", now))
// New code
- assertEquals("new-code", resend(this, "new-code", now))
+ assertEquals("new-code", send(this, "new-code", now))
// Good code
- assertEquals(Pair(true, false), cTry(this, "new-code", now))
+ assertEquals(Triple(true, false, false), cTry(this, "new-code", now))
}
// Check retransmission and expiration
create("good-code", now).run {
// Failed to send retransmit
- assertEquals("good-code", resend(this, "new-code", now))
+ assertEquals("good-code", send(this, "new-code", now))
// Code successfully sent and still valid
- send(this, now)
- assertNull(resend(this, "new-code", now))
+ markSent(this, now)
+ assertNull(send(this, "new-code", now))
// Code is still valid but shoud be resent
- assertEquals("good-code", resend(this, "new-code", retransmit))
+ assertEquals("good-code", send(this, "new-code", retransmit))
// Good code fail because expired
- assertEquals(Pair(false, false), cTry(this, "good-code", expired))
+ assertEquals(Triple(false, false, true), cTry(this, "good-code", expired))
// New code because expired
- assertEquals("new-code", resend(this, "new-code", expired))
+ assertEquals("new-code", send(this, "new-code", expired))
// Code successfully sent and still valid
- send(this, expired)
- assertNull(resend(this, "another-code", expired))
+ markSent(this, expired)
+ assertNull(send(this, "another-code", expired))
// Old code no longer workds
- assertEquals(Pair(false, false), cTry(this, "good-code", expired))
+ assertEquals(Triple(false, false, false), cTry(this, "good-code", expired))
// New code works
- assertEquals(Pair(true, false), cTry(this, "new-code", expired))
+ assertEquals(Triple(true, false, false), cTry(this, "new-code", expired))
}
}}
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index c35dc721..951d24f5 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -26,6 +26,7 @@ import java.io.ByteArrayOutputStream
import java.io.File
import java.util.zip.DeflaterOutputStream
import kotlin.test.*
+import kotlin.random.Random
import kotlinx.coroutines.*
import kotlinx.serialization.json.*
import net.taler.common.errorcodes.TalerErrorCode
@@ -40,13 +41,19 @@ import tech.libeufin.util.*
val merchantPayto = IbanPayTo(genIbanPaytoUri())
val exchangePayto = IbanPayTo(genIbanPaytoUri())
val customerPayto = IbanPayTo(genIbanPaytoUri())
-val unknownPayto = IbanPayTo(genIbanPaytoUri())
+val unknownPayto = IbanPayTo(genIbanPaytoUri())
+var tmpPayTo = IbanPayTo(genIbanPaytoUri())
val paytos = mapOf(
"merchant" to merchantPayto,
"exchange" to exchangePayto,
"customer" to customerPayto
)
+fun genTmpPayTo(): IbanPayTo {
+ tmpPayTo = IbanPayTo(genIbanPaytoUri())
+ return tmpPayTo
+}
+
fun setup(
conf: String = "test.conf",
lambda: suspend (Database, BankConfig) -> Unit
@@ -85,7 +92,11 @@ fun bankSetup(
isTalerExchange = false,
isPublic = false,
bonus = bonus,
- checkPaytoIdempotent = false
+ checkPaytoIdempotent = false,
+ email = null,
+ phone = null,
+ cashoutPayto = null,
+ tanChannel = null
))
assertEquals(AccountCreationResult.Success, db.account.create(
login = "exchange",
@@ -96,7 +107,11 @@ fun bankSetup(
isTalerExchange = true,
isPublic = false,
bonus = bonus,
- checkPaytoIdempotent = false
+ checkPaytoIdempotent = false,
+ email = null,
+ phone = null,
+ cashoutPayto = null,
+ tanChannel = null
))
assertEquals(AccountCreationResult.Success, db.account.create(
login = "customer",
@@ -107,7 +122,11 @@ fun bankSetup(
isTalerExchange = false,
isPublic = false,
bonus = bonus,
- checkPaytoIdempotent = false
+ checkPaytoIdempotent = false,
+ email = null,
+ phone = null,
+ cashoutPayto = null,
+ tanChannel = null
))
// Create admin account
assertEquals(AccountCreationResult.Success, maybeCreateAdminAccount(db, ctx, "admin-password"))
@@ -163,20 +182,30 @@ suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String
}
}
+/** Check [account] tan channel and info */
+suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair<TanChannel?, String?> {
+ val res = client.getA("/accounts/$account").assertOkJson<AccountData>()
+ val channel: TanChannel? = res.tan_channel
+ return Pair(channel, when (channel) {
+ TanChannel.sms -> res.contact_data!!.phone.get()
+ TanChannel.email -> res.contact_data!!.email.get()
+ null -> null
+ else -> null
+ })
+}
+
/** Perform a bank transaction of [amount] [from] account [to] account with [subject} */
suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long {
- return client.post("/accounts/$from/transactions") {
- basicAuth("$from", "$from-password")
+ return client.postA("/accounts/$from/transactions") {
json {
- "payto_uri" to "${paytos[to]}?message=${subject.encodeURLQueryComponent()}&amount=$amount"
+ "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLQueryComponent()}&amount=$amount"
}
- }.assertOkJson<TransactionCreateResponse>().row_id
+ }.maybeChallenge().assertOkJson<TransactionCreateResponse>().row_id
}
/** Perform a taler outgoing transaction of [amount] from exchange to merchant */
suspend fun ApplicationTestBuilder.transfer(amount: String) {
- client.post("/accounts/exchange/taler-wire-gateway/transfer") {
- pwAuth("exchange")
+ client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
json {
"request_uid" to randHashCode()
"amount" to TalerAmount(amount)
@@ -220,11 +249,7 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) {
}
} else {
res
- }.assertOkJson<CashoutPending> {
- client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") {
- json { "tan" to smsCode("+99") }
- }.assertNoContent()
- }
+ }.assertOk()
}
/** Perform a whithrawal operation of [amount] from customer */
@@ -234,7 +259,7 @@ suspend fun ApplicationTestBuilder.withdrawal(amount: String) {
}.assertOkJson<BankAccountCreateWithdrawalResponse> {
val uuid = it.taler_withdraw_uri.split("/").last()
withdrawalSelect(uuid)
- client.postA("/withdrawals/${uuid}/confirm")
+ client.postA("/accounts/merchant/withdrawals/${uuid}/confirm")
.assertNoContent()
}
}
@@ -251,6 +276,18 @@ suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) {
}.assertNoContent()
}
+suspend fun ApplicationTestBuilder.fillTanInfo(account: String) {
+ client.patch("/accounts/$account") {
+ pwAuth("admin")
+ json {
+ "contact_data" to obj {
+ "phone" to "+${Random.nextInt(0, 10000)}"
+ }
+ "tan_channel" to "sms"
+ }
+ }.assertNoContent()
+}
+
suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) {
client.post("/taler-integration/withdrawal-operation/$uuid") {
json {
@@ -265,7 +302,7 @@ suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount {
.assertOkJson<ConversionResponse>().amount_credit
}
-suspend fun smsCode(info: String): String? {
+suspend fun tanCode(info: String): String? {
val file = File("/tmp/tan-$info.txt");
if (file.exists()) {
val code = file.readText()
@@ -280,7 +317,7 @@ suspend fun smsCode(info: String): String? {
/* ----- Assert ----- */
suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse {
- assertEquals(status, this.status);
+ assertEquals(status, this.status, "$err")
if (err != null) assertErr(err)
return this
}
@@ -288,6 +325,8 @@ suspend fun HttpResponse.assertOk(): HttpResponse
= assertStatus(HttpStatusCode.OK, null)
suspend fun HttpResponse.assertNoContent(): HttpResponse
= assertStatus(HttpStatusCode.NoContent, null)
+suspend fun HttpResponse.assertAccepted(): HttpResponse
+ = assertStatus(HttpStatusCode.Accepted, null)
suspend fun HttpResponse.assertNotFound(err: TalerErrorCode?): HttpResponse
= assertStatus(HttpStatusCode.NotFound, err)
suspend fun HttpResponse.assertUnauthorized(): HttpResponse
@@ -308,6 +347,33 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse {
return this
}
+suspend fun HttpResponse.maybeChallenge(): HttpResponse {
+ return if (this.status == HttpStatusCode.Accepted) {
+ this.assertChallenge()
+ } else {
+ this
+ }
+}
+
+suspend fun HttpResponse.assertChallenge(
+ check: suspend (TanChannel, String) -> Unit = { _, _ -> }
+): HttpResponse {
+ val id = assertAcceptedJson<TanChallenge>().challenge_id
+ val username = call.request.url.pathSegments[2]
+ 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()
+ return call.client.request(this.call.request.url) {
+ pwAuth(username)
+ method = call.request.method
+ headers["X-Challenge-Id"] = "$id"
+ }
+}
+
suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) {
val start = System.currentTimeMillis()
lambda()
@@ -360,6 +426,13 @@ inline suspend fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {
return body
}
+inline suspend fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> Unit = {}): B {
+ assertAccepted()
+ val body = json<B>()
+ lambda(body)
+ return body
+}
+
/* ----- Auth ----- */
/** Auto auth get request */
diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt
index 6a915d58..92fdfd9c 100644
--- a/bank/src/test/kotlin/routines.kt
+++ b/bank/src/test/kotlin/routines.kt
@@ -35,7 +35,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
body: JsonObject? = null,
requireExchange: Boolean = false,
requireAdmin: Boolean = false,
- withAdmin: Boolean = false
+ allowAdmin: Boolean = false
) {
// No body when authentication must happen before parsing the body
@@ -63,7 +63,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
this.method = method
pwAuth("merchant")
}.assertUnauthorized()
- } else if (!withAdmin) {
+ } else if (!allowAdmin) {
// Check no admin
client.request(path) {
this.method = method
@@ -256,7 +256,7 @@ inline suspend fun <reified B> ApplicationTestBuilder.statusRoutine(
}
}
delay(100)
- client.post("/withdrawals/$confirmed_uuid/confirm").assertNoContent()
+ client.postA("/accounts/customer/withdrawals/$confirmed_uuid/confirm").assertNoContent()
}
// Polling abort
@@ -274,7 +274,7 @@ inline suspend fun <reified B> ApplicationTestBuilder.statusRoutine(
}
}
delay(100)
- client.post("/withdrawals/$aborted_uuid/abort").assertNoContent()
+ client.post("/taler-integration/withdrawal-operation/$aborted_uuid/abort").assertNoContent()
}
}
} \ No newline at end of file
diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql
index 6bf7420a..5272eeae 100644
--- a/database-versioning/libeufin-bank-0001.sql
+++ b/database-versioning/libeufin-bank-0001.sql
@@ -21,10 +21,7 @@ CREATE SCHEMA libeufin_bank;
SET search_path TO libeufin_bank;
CREATE TYPE taler_amount
- AS
- (val INT8
- ,frac INT4
- );
+ AS (val INT8 ,frac INT4);
COMMENT ON TYPE taler_amount
IS 'Stores an amount, fraction is in units of 1/100000000 of the base value';
@@ -59,7 +56,7 @@ CREATE TYPE rounding_mode
-- start of: bank accounts
CREATE TABLE IF NOT EXISTS customers
- (customer_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ (customer_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,login TEXT NOT NULL UNIQUE
,password_hash TEXT NOT NULL
,name TEXT
@@ -72,27 +69,10 @@ COMMENT ON COLUMN customers.cashout_payto
COMMENT ON COLUMN customers.name
IS 'Full name of the customer.';
-CREATE TABLE IF NOT EXISTS bearer_tokens
- (bearer_token_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
- ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32)
- ,creation_time INT8
- ,expiration_time INT8
- ,scope token_scope_enum
- ,is_refreshable BOOLEAN
- ,bank_customer BIGINT NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE
-);
-COMMENT ON TABLE bearer_tokens
- IS 'Login tokens associated with one bank customer. There is currently'
- ' no garbage collector that deletes the expired tokens from the table';
-COMMENT ON COLUMN bearer_tokens.bank_customer
- IS 'The customer that directly created this token, or the customer that'
- ' created the very first token that originated all the refreshes until'
- ' this token was created.';
-
CREATE TABLE IF NOT EXISTS bank_accounts
- (bank_account_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ (bank_account_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,internal_payto_uri TEXT NOT NULL UNIQUE
- ,owning_customer_id BIGINT NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with customers
+ ,owning_customer_id INT8 NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with customers
REFERENCES customers(customer_id)
ON DELETE CASCADE
,is_public BOOLEAN DEFAULT FALSE NOT NULL -- privacy by default
@@ -116,8 +96,26 @@ can be publicly shared';
COMMENT ON COLUMN bank_accounts.owning_customer_id
IS 'Login that owns the bank account';
+CREATE TABLE IF NOT EXISTS bearer_tokens
+ (bearer_token_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32)
+ ,creation_time INT8
+ ,expiration_time INT8
+ ,scope token_scope_enum
+ ,is_refreshable BOOLEAN
+ ,bank_customer INT8 NOT NULL
+ REFERENCES customers(customer_id)
+ ON DELETE CASCADE
+);
+COMMENT ON TABLE bearer_tokens
+ IS 'Login tokens associated with one bank customer.';
+COMMENT ON COLUMN bearer_tokens.bank_customer
+ IS 'The customer that directly created this token, or the customer that'
+ ' created the very first token that originated all the refreshes until'
+ ' this token was created.';
+
CREATE TABLE IF NOT EXISTS iban_history
- (iban TEXT PRIMARY key
+ (iban TEXT PRIMARY KEY
,creation_time INT8 NOT NULL
);
COMMENT ON TABLE iban_history IS 'Track all generated iban, some might be unused.';
@@ -127,21 +125,19 @@ COMMENT ON TABLE iban_history IS 'Track all generated iban, some might be unused
-- start of: money transactions
CREATE TABLE IF NOT EXISTS bank_account_transactions
- (bank_transaction_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ (bank_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,creditor_payto_uri TEXT NOT NULL
,creditor_name TEXT NOT NULL
,debtor_payto_uri TEXT NOT NULL
,debtor_name TEXT NOT NULL
,subject TEXT NOT NULL
,amount taler_amount NOT NULL
- ,transaction_date BIGINT NOT NULL -- is this ISO20022 terminology? document format (microseconds since epoch)
+ ,transaction_date INT8 NOT NULL
,account_servicer_reference TEXT
,payment_information_id TEXT
,end_to_end_id TEXT
,direction direction_enum NOT NULL
- ,bank_account_id BIGINT NOT NULL
- REFERENCES bank_accounts(bank_account_id)
- ON DELETE CASCADE ON UPDATE RESTRICT
+ ,bank_account_id INT8 NOT NULL REFERENCES bank_accounts(bank_account_id)
);
COMMENT ON COLUMN bank_account_transactions.direction
@@ -157,7 +153,7 @@ COMMENT ON COLUMN bank_account_transactions.bank_account_id
-- start of: TAN challenge
CREATE TABLE IF NOT EXISTS challenges
- (challenge_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE,
+ (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE,
code TEXT NOT NULL,
creation_date INT8 NOT NULL,
expiration_date INT8 NOT NULL,
@@ -184,27 +180,23 @@ COMMENT ON COLUMN challenges.confirmation_date
-- start of: cashout management
CREATE TABLE IF NOT EXISTS cashout_operations
- (cashout_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ (cashout_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,request_uid BYTEA NOT NULL PRIMARY KEY CHECK (LENGTH(request_uid)=32)
,amount_debit taler_amount NOT NULL
,amount_credit taler_amount NOT NULL
,subject TEXT NOT NULL
- ,creation_time BIGINT NOT NULL
- ,bank_account BIGINT NOT NULL
+ ,creation_time INT8 NOT NULL
+ ,bank_account INT8 NOT NULL
REFERENCES bank_accounts(bank_account_id)
- ON DELETE CASCADE
- ON UPDATE RESTRICT
- ,challenge BIGINT NOT NULL UNIQUE
+ ,challenge INT8 NOT NULL UNIQUE
REFERENCES challenges(challenge_id)
- ON DELETE CASCADE
- ON UPDATE RESTRICT
- ,tan_channel TEXT NULL DEFAULT NULL -- TODO should be tan_enum but might be removed in the future
+ ON DELETE SET NULL
+ ,tan_channel TEXT NULL DEFAULT NULL
,tan_info TEXT NULL DEFAULT NULL
,aborted BOOLEAN NOT NULL DEFAULT FALSE
- ,local_transaction BIGINT UNIQUE DEFAULT NULL-- FIXME: Comment that the transaction only gets created after the TAN confirmation
+ ,local_transaction INT8 UNIQUE DEFAULT NULL
REFERENCES bank_account_transactions(bank_transaction_id)
- ON DELETE RESTRICT
- ON UPDATE RESTRICT
+ ON DELETE CASCADE
);
COMMENT ON COLUMN cashout_operations.bank_account IS 'Bank amount to debit during confirmation';
COMMENT ON COLUMN cashout_operations.challenge IS 'TAN challenge used to confirm the operation';
@@ -216,31 +208,28 @@ COMMENT ON COLUMN cashout_operations.tan_info IS 'Info of the last successful tr
-- start of: Taler integration
CREATE TABLE IF NOT EXISTS taler_exchange_outgoing
- (exchange_outgoing_id BIGINT GENERATED BY DEFAULT AS IDENTITY
+ (exchange_outgoing_id INT8 GENERATED BY DEFAULT AS IDENTITY
,request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64)
,wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32)
,exchange_base_url TEXT NOT NULL
- ,bank_transaction BIGINT UNIQUE NOT NULL
+ ,bank_transaction INT8 UNIQUE NOT NULL
REFERENCES bank_account_transactions(bank_transaction_id)
- ON DELETE RESTRICT
- ON UPDATE RESTRICT
- ,creditor_account_id BIGINT NOT NULL
+ ON DELETE CASCADE
+ ,creditor_account_id INT8 NOT NULL
REFERENCES bank_accounts(bank_account_id)
- ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE TABLE IF NOT EXISTS taler_exchange_incoming
- (exchange_incoming_id BIGINT GENERATED BY DEFAULT AS IDENTITY
+ (exchange_incoming_id INT8 GENERATED BY DEFAULT AS IDENTITY
,reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32)
- ,bank_transaction BIGINT UNIQUE NOT NULL
+ ,bank_transaction INT8 UNIQUE NOT NULL
REFERENCES bank_account_transactions(bank_transaction_id)
- ON DELETE RESTRICT
- ON UPDATE RESTRICT
+ ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS taler_withdrawal_operations
- (withdrawal_id BIGINT GENERATED BY DEFAULT AS IDENTITY
- ,withdrawal_uuid uuid NOT NULL PRIMARY KEY
+ (withdrawal_id INT8 GENERATED BY DEFAULT AS IDENTITY
+ ,withdrawal_uuid uuid NOT NULL UNIQUE
,amount taler_amount NOT NULL
,selection_done BOOLEAN DEFAULT FALSE NOT NULL
,aborted BOOLEAN DEFAULT FALSE NOT NULL
@@ -248,10 +237,9 @@ CREATE TABLE IF NOT EXISTS taler_withdrawal_operations
,reserve_pub BYTEA UNIQUE CHECK (LENGTH(reserve_pub)=32)
,subject TEXT
,selected_exchange_payto TEXT
- ,wallet_bank_account BIGINT NOT NULL
+ ,wallet_bank_account INT8 NOT NULL
REFERENCES bank_accounts(bank_account_id)
- ON DELETE RESTRICT
- ON UPDATE RESTRICT
+ ON DELETE CASCADE
);
COMMENT ON COLUMN taler_withdrawal_operations.selection_done
IS 'Signals whether the wallet specified the exchange and gave the reserve public key';
@@ -264,32 +252,44 @@ COMMENT ON COLUMN taler_withdrawal_operations.confirmation_done
CREATE TABLE IF NOT EXISTS bank_stats (
timeframe stat_timeframe_enum NOT NULL
,start_time timestamp NOT NULL
- ,taler_in_count BIGINT NOT NULL DEFAULT 0
+ ,taler_in_count INT8 NOT NULL DEFAULT 0
,taler_in_volume taler_amount NOT NULL DEFAULT (0, 0)
- ,taler_out_count BIGINT NOT NULL DEFAULT 0
+ ,taler_out_count INT8 NOT NULL DEFAULT 0
,taler_out_volume taler_amount NOT NULL DEFAULT (0, 0)
- ,cashin_count BIGINT NOT NULL DEFAULT 0
+ ,cashin_count INT8 NOT NULL DEFAULT 0
,cashin_regional_volume taler_amount NOT NULL DEFAULT (0, 0)
,cashin_fiat_volume taler_amount NOT NULL DEFAULT (0, 0)
- ,cashout_count BIGINT NOT NULL DEFAULT 0
+ ,cashout_count INT8 NOT NULL DEFAULT 0
,cashout_regional_volume taler_amount NOT NULL DEFAULT (0, 0)
,cashout_fiat_volume taler_amount NOT NULL DEFAULT (0, 0)
,PRIMARY KEY (start_time, timeframe)
);
--- TODO garbage collection
-COMMENT ON TABLE bank_stats IS 'Stores statistics about the bank usage.';
-COMMENT ON COLUMN bank_stats.timeframe IS 'particular timeframe that this row accounts for';
-COMMENT ON COLUMN bank_stats.start_time IS 'timestamp of the start of the timeframe that this row accounts for, truncated according to the precision of the timeframe';
-COMMENT ON COLUMN bank_stats.taler_out_count IS 'how many internal payments were made by a Taler exchange';
-COMMENT ON COLUMN bank_stats.taler_out_volume IS 'how much internal currency was paid by a Taler exchange';
-COMMENT ON COLUMN bank_stats.taler_in_count IS 'how many internal payments were made to a Taler exchange';
-COMMENT ON COLUMN bank_stats.taler_in_volume IS 'how much internal currency was paid to a Taler exchange';
-COMMENT ON COLUMN bank_stats.cashin_count IS 'how many cashin operations took place in the timeframe';
-COMMENT ON COLUMN bank_stats.cashin_regional_volume IS 'how much regional currency was cashed in in the timeframe';
-COMMENT ON COLUMN bank_stats.cashin_fiat_volume IS 'how much fiat currency was cashed in in the timeframe';
-COMMENT ON COLUMN bank_stats.cashout_count IS 'how many cashout operations took place in the timeframe';
-COMMENT ON COLUMN bank_stats.cashout_regional_volume IS 'how much regional currency was payed by the bank to customers in the timeframe';
-COMMENT ON COLUMN bank_stats.cashout_fiat_volume IS 'how much fiat currency was payed by the bank to customers in the timeframe';
+COMMENT ON TABLE bank_stats
+ IS 'Stores statistics about the bank usage.';
+COMMENT ON COLUMN bank_stats.timeframe
+ IS 'particular timeframe that this row accounts for';
+COMMENT ON COLUMN bank_stats.start_time
+ IS 'timestamp of the start of the timeframe that this row accounts for, truncated according to the precision of the timeframe';
+COMMENT ON COLUMN bank_stats.taler_out_count
+ IS 'how many internal payments were made by a Taler exchange';
+COMMENT ON COLUMN bank_stats.taler_out_volume
+ IS 'how much internal currency was paid by a Taler exchange';
+COMMENT ON COLUMN bank_stats.taler_in_count
+ IS 'how many internal payments were made to a Taler exchange';
+COMMENT ON COLUMN bank_stats.taler_in_volume
+ IS 'how much internal currency was paid to a Taler exchange';
+COMMENT ON COLUMN bank_stats.cashin_count
+ IS 'how many cashin operations took place in the timeframe';
+COMMENT ON COLUMN bank_stats.cashin_regional_volume
+ IS 'how much regional currency was cashed in in the timeframe';
+COMMENT ON COLUMN bank_stats.cashin_fiat_volume
+ IS 'how much fiat currency was cashed in in the timeframe';
+COMMENT ON COLUMN bank_stats.cashout_count
+ IS 'how many cashout operations took place in the timeframe';
+COMMENT ON COLUMN bank_stats.cashout_regional_volume
+ IS 'how much regional currency was payed by the bank to customers in the timeframe';
+COMMENT ON COLUMN bank_stats.cashout_fiat_volume
+ IS 'how much fiat currency was payed by the bank to customers in the timeframe';
-- end of: Statistics
diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql
new file mode 100644
index 00000000..0859d80f
--- /dev/null
+++ b/database-versioning/libeufin-bank-0002.sql
@@ -0,0 +1,90 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2023 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER 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 General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+BEGIN;
+
+SELECT _v.register_patch('libeufin-bank-0002', NULL, NULL);
+SET search_path TO libeufin_bank;
+
+-- Forget about all pending operations
+DELETE FROM cashout_operations WHERE local_transaction IS NULL;
+
+-- Remove challenge logic from cashout tables
+ALTER TABLE cashout_operations
+ DROP COLUMN challenge,
+ DROP COLUMN tan_channel,
+ DROP COLUMN tan_info,
+ DROP COLUMN aborted,
+ ALTER COLUMN local_transaction SET NOT NULL;
+
+DROP TABLE challenges;
+
+ALTER TABLE customers
+ ADD tan_channel tan_enum NULL;
+
+CREATE TYPE op_enum
+ AS ENUM ('account_reconfig', 'account_auth_reconfig', 'account_delete', 'bank_transaction', 'cashout', 'withdrawal');
+
+CREATE TABLE tan_challenges
+ (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ ,body TEXT NOT NULL
+ ,op op_enum NOT NULL
+ ,code TEXT NOT NULL
+ ,creation_date INT8 NOT NULL
+ ,expiration_date INT8 NOT NULL
+ ,retransmission_date INT8 NOT NULL DEFAULT 0
+ ,confirmation_date INT8 DEFAULT NULL
+ ,retry_counter INT4 NOT NULL
+ ,customer INT8 NOT NULL
+ REFERENCES customers(customer_id)
+ ON DELETE CASCADE
+ ,tan_channel tan_enum NULL DEFAULT NULL
+ ,tan_info TEXT NULL DEFAULT NULL
+);
+COMMENT ON TABLE tan_challenges IS 'Stores 2FA challenges';
+COMMENT ON COLUMN tan_challenges.op IS 'The protected operation to run after the challenge';
+COMMENT ON COLUMN tan_challenges.code IS 'The pin code sent to the user and verified';
+COMMENT ON COLUMN tan_challenges.creation_date IS 'Creation date of the code';
+COMMENT ON COLUMN tan_challenges.retransmission_date IS 'When did we last transmit the challenge to the user';
+COMMENT ON COLUMN tan_challenges.expiration_date IS 'When will the code expire';
+COMMENT ON COLUMN tan_challenges.confirmation_date IS 'When was this challenge successfully verified, NULL if pending';
+COMMENT ON COLUMN tan_challenges.retry_counter IS 'How many tries are left for this code must be > 0';
+COMMENT ON COLUMN tan_challenges.tan_channel IS 'TAN channel to use, if null use customer configured one';
+COMMENT ON COLUMN tan_challenges.tan_info IS 'TAN info to use, if null use customer configured one';
+
+CREATE INDEX tan_challenges_expiration_index
+ ON tan_challenges (expiration_date);
+COMMENT ON INDEX tan_challenges_expiration_index
+ IS 'for garbage collection';
+
+CREATE INDEX bearer_tokens_expiration_index
+ ON bearer_tokens (expiration_time);
+COMMENT ON INDEX bearer_tokens_expiration_index
+ IS 'for garbage collection';
+
+CREATE INDEX bank_account_transactions_expiration_index
+ ON bank_account_transactions (transaction_date);
+COMMENT ON INDEX bank_account_transactions_expiration_index
+ IS 'for garbage collection';
+
+ALTER TABLE taler_withdrawal_operations
+ ADD creation_date INT8 NOT NULL;
+CREATE INDEX taler_withdrawal_operations_expiration_index
+ ON taler_withdrawal_operations (creation_date);
+COMMENT ON INDEX taler_withdrawal_operations_expiration_index
+ IS 'for garbage collection';
+
+
+COMMIT;
diff --git a/database-versioning/libeufin-bank-drop.sql b/database-versioning/libeufin-bank-drop.sql
index 123481a1..3746c28d 100644
--- a/database-versioning/libeufin-bank-drop.sql
+++ b/database-versioning/libeufin-bank-drop.sql
@@ -4,6 +4,7 @@ BEGIN;
-- legacy database schema too. That's acceptable as the
-- legacy schema is being removed.
SELECT _v.unregister_patch('libeufin-bank-0001');
+SELECT _v.unregister_patch('libeufin-bank-0002');
DROP SCHEMA libeufin_bank CASCADE;
COMMIT;
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
index 6714a656..99b0bee3 100644
--- a/database-versioning/libeufin-bank-procedures.sql
+++ b/database-versioning/libeufin-bank-procedures.sql
@@ -31,7 +31,7 @@ CREATE FUNCTION amount_normalize(
LANGUAGE plpgsql AS $$
BEGIN
normalized.val = amount.val + amount.frac / 100000000;
- IF (normalized.val > 1::bigint<<52) THEN
+ IF (normalized.val > 1::INT8<<52) THEN
RAISE EXCEPTION 'amount value overflowed';
END IF;
normalized.frac = amount.frac % 100000000;
@@ -85,7 +85,7 @@ COMMENT ON FUNCTION amount_left_minus_right
IS 'Subtracts the right amount from the left and returns the difference and TRUE, if the left amount is larger than the right, or an invalid amount and FALSE otherwise.';
CREATE FUNCTION account_balance_is_sufficient(
- IN in_account_id BIGINT,
+ IN in_account_id INT8,
IN in_amount taler_amount,
OUT out_balance_insufficient BOOLEAN
)
@@ -140,27 +140,28 @@ END IF;
END $$;
COMMENT ON FUNCTION account_balance_is_sufficient IS 'Check if an account have enough fund to transfer an amount.';
-CREATE FUNCTION customer_delete(
+CREATE FUNCTION account_delete(
IN in_login TEXT,
- OUT out_nx_customer BOOLEAN,
- OUT out_balance_not_zero BOOLEAN
+ IN in_is_tan BOOLEAN,
+ OUT out_not_found BOOLEAN,
+ OUT out_balance_not_zero BOOLEAN,
+ OUT out_tan_required BOOLEAN
)
LANGUAGE plpgsql AS $$
DECLARE
-my_customer_id BIGINT;
+my_customer_id INT8;
my_balance_val INT8;
my_balance_frac INT4;
BEGIN
--- check if login exists
-SELECT customer_id
- INTO my_customer_id
+-- check if login exists and if 2FA is required
+SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL)
+ INTO my_customer_id, out_tan_required
FROM customers
WHERE login = in_login;
IF NOT FOUND THEN
- out_nx_customer=TRUE;
+ out_not_found=TRUE;
RETURN;
END IF;
-out_nx_customer=FALSE;
-- get the balance
SELECT
@@ -174,32 +175,36 @@ SELECT
IF NOT FOUND THEN
RAISE EXCEPTION 'Invariant failed: customer lacks bank account';
END IF;
+
-- check that balance is zero.
IF my_balance_val != 0 OR my_balance_frac != 0 THEN
out_balance_not_zero=TRUE;
RETURN;
END IF;
-out_balance_not_zero=FALSE;
+
+-- check tan required
+IF out_tan_required THEN
+ RETURN;
+END IF;
-- actual deletion
DELETE FROM customers WHERE login = in_login;
END $$;
-COMMENT ON FUNCTION customer_delete(TEXT)
- IS 'Deletes a customer (and its bank account via cascade) if the balance is zero';
+COMMENT ON FUNCTION account_delete IS 'Deletes an account if the balance is zero';
CREATE PROCEDURE register_outgoing(
IN in_request_uid BYTEA,
IN in_wtid BYTEA,
IN in_exchange_base_url TEXT,
- IN in_debtor_account_id BIGINT,
- IN in_creditor_account_id BIGINT,
- IN in_debit_row_id BIGINT,
- IN in_credit_row_id BIGINT
+ IN in_debtor_account_id INT8,
+ IN in_creditor_account_id INT8,
+ IN in_debit_row_id INT8,
+ IN in_credit_row_id INT8
)
LANGUAGE plpgsql AS $$
DECLARE
local_amount taler_amount;
- local_bank_account_id BIGINT;
+ local_bank_account_id INT8;
BEGIN
-- register outgoing transaction
INSERT
@@ -230,12 +235,12 @@ COMMENT ON PROCEDURE register_outgoing
CREATE PROCEDURE register_incoming(
IN in_reserve_pub BYTEA,
- IN in_tx_row_id BIGINT
+ IN in_tx_row_id INT8
)
LANGUAGE plpgsql AS $$
DECLARE
local_amount taler_amount;
-local_bank_account_id BIGINT;
+local_bank_account_id INT8;
BEGIN
-- Register incoming transaction
INSERT
@@ -266,7 +271,7 @@ CREATE FUNCTION taler_transfer(
IN in_exchange_base_url TEXT,
IN in_credit_account_payto TEXT,
IN in_username TEXT,
- IN in_timestamp BIGINT,
+ IN in_timestamp INT8,
-- Error status
OUT out_debtor_not_found BOOLEAN,
OUT out_debtor_not_exchange BOOLEAN,
@@ -275,14 +280,14 @@ CREATE FUNCTION taler_transfer(
OUT out_request_uid_reuse BOOLEAN,
OUT out_exchange_balance_insufficient BOOLEAN,
-- Success return
- OUT out_tx_row_id BIGINT,
- OUT out_timestamp BIGINT
+ OUT out_tx_row_id INT8,
+ OUT out_timestamp INT8
)
LANGUAGE plpgsql AS $$
DECLARE
-exchange_bank_account_id BIGINT;
-receiver_bank_account_id BIGINT;
-credit_row_id BIGINT;
+exchange_bank_account_id INT8;
+receiver_bank_account_id INT8;
+credit_row_id INT8;
BEGIN
-- Check for idempotence and conflict
SELECT (amount != in_amount
@@ -356,7 +361,7 @@ CREATE FUNCTION taler_add_incoming(
IN in_amount taler_amount,
IN in_debit_account_payto TEXT,
IN in_username TEXT,
- IN in_timestamp BIGINT,
+ IN in_timestamp INT8,
-- Error status
OUT out_creditor_not_found BOOLEAN,
OUT out_creditor_not_exchange BOOLEAN,
@@ -365,12 +370,12 @@ CREATE FUNCTION taler_add_incoming(
OUT out_reserve_pub_reuse BOOLEAN,
OUT out_debitor_balance_insufficient BOOLEAN,
-- Success return
- OUT out_tx_row_id BIGINT
+ OUT out_tx_row_id INT8
)
LANGUAGE plpgsql AS $$
DECLARE
-exchange_bank_account_id BIGINT;
-sender_bank_account_id BIGINT;
+exchange_bank_account_id INT8;
+sender_bank_account_id INT8;
BEGIN
-- Check conflict
SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub
@@ -436,18 +441,20 @@ CREATE FUNCTION bank_transaction(
IN in_debit_account_username TEXT,
IN in_subject TEXT,
IN in_amount taler_amount,
- IN in_timestamp BIGINT,
+ IN in_timestamp INT8,
+ IN in_is_tan BOOLEAN,
-- Error status
OUT out_creditor_not_found BOOLEAN,
OUT out_debtor_not_found BOOLEAN,
OUT out_same_account BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
OUT out_creditor_admin BOOLEAN,
+ OUT out_tan_required BOOLEAN,
-- Success return
- OUT out_credit_bank_account_id BIGINT,
- OUT out_debit_bank_account_id BIGINT,
- OUT out_credit_row_id BIGINT,
- OUT out_debit_row_id BIGINT,
+ OUT out_credit_bank_account_id INT8,
+ OUT out_debit_bank_account_id INT8,
+ OUT out_credit_row_id INT8,
+ OUT out_debit_row_id INT8,
OUT out_creditor_is_exchange BOOLEAN,
OUT out_debtor_is_exchange BOOLEAN
)
@@ -466,15 +473,15 @@ ELSIF out_creditor_admin THEN
RETURN;
END IF;
-- Find debit bank account id and check it's a different account
-SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id
- INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account
+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
+ 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
WHERE login = in_debit_account_username;
IF NOT FOUND THEN
out_debtor_not_found=TRUE;
RETURN;
-ELSIF out_same_account THEN
+ELSIF out_same_account OR out_tan_required THEN
RETURN;
END IF;
-- Perform bank transfer
@@ -503,6 +510,7 @@ CREATE FUNCTION create_taler_withdrawal(
IN in_account_username TEXT,
IN in_withdrawal_uuid UUID,
IN in_amount taler_amount,
+ IN in_now_date INT8,
-- Error status
OUT out_account_not_found BOOLEAN,
OUT out_account_is_exchange BOOLEAN,
@@ -510,9 +518,9 @@ CREATE FUNCTION create_taler_withdrawal(
)
LANGUAGE plpgsql AS $$
DECLARE
-account_id BIGINT;
+account_id INT8;
BEGIN
--- check account exists
+-- Check account exists
SELECT bank_account_id, is_taler_exchange
INTO account_id, out_account_is_exchange
FROM bank_accounts
@@ -525,7 +533,7 @@ ELSIF out_account_is_exchange THEN
RETURN;
END IF;
--- check enough funds
+-- Check enough funds
SELECT account_balance_is_sufficient(account_id, in_amount) INTO out_balance_insufficient;
IF out_balance_insufficient THEN
RETURN;
@@ -533,8 +541,8 @@ END IF;
-- Create withdrawal operation
INSERT INTO taler_withdrawal_operations
- (withdrawal_uuid, wallet_bank_account, amount)
- VALUES (in_withdrawal_uuid, account_id, in_amount);
+ (withdrawal_uuid, wallet_bank_account, amount, creation_date)
+ VALUES (in_withdrawal_uuid, account_id, in_amount, in_now_date);
END $$;
COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal operation';
@@ -633,14 +641,17 @@ END $$;
COMMENT ON FUNCTION abort_taler_withdrawal IS 'Abort a withdrawal operation.';
CREATE FUNCTION confirm_taler_withdrawal(
+ IN in_login TEXT,
IN in_withdrawal_uuid uuid,
- IN in_confirmation_date BIGINT,
+ IN in_confirmation_date INT8,
+ IN in_is_tan BOOLEAN,
OUT out_no_op BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
OUT out_creditor_not_found BOOLEAN,
OUT out_exchange_not_found BOOLEAN,
OUT out_not_selected BOOLEAN,
- OUT out_aborted BOOLEAN
+ OUT out_aborted BOOLEAN,
+ OUT out_tan_required BOOLEAN
)
LANGUAGE plpgsql AS $$
DECLARE
@@ -648,27 +659,32 @@ DECLARE
subject_local TEXT;
reserve_pub_local BYTEA;
selected_exchange_payto_local TEXT;
- wallet_bank_account_local BIGINT;
+ wallet_bank_account_local INT8;
amount_local taler_amount;
- exchange_bank_account_id BIGINT;
- tx_row_id BIGINT;
+ exchange_bank_account_id INT8;
+ tx_row_id INT8;
BEGIN
-SELECT -- Really no-star policy and instead DECLARE almost one var per column?
+-- Check op exists
+SELECT
confirmation_done,
aborted, NOT selection_done,
reserve_pub, subject,
selected_exchange_payto,
wallet_bank_account,
- (amount).val, (amount).frac
+ (amount).val, (amount).frac,
+ (NOT in_is_tan AND tan_channel IS NOT NULL)
INTO
already_confirmed,
out_aborted, out_not_selected,
reserve_pub_local, subject_local,
selected_exchange_payto_local,
wallet_bank_account_local,
- amount_local.val, amount_local.frac
+ amount_local.val, amount_local.frac,
+ out_tan_required
FROM taler_withdrawal_operations
- WHERE withdrawal_uuid=in_withdrawal_uuid;
+ JOIN bank_accounts ON wallet_bank_account=bank_account_id
+ JOIN customers ON owning_customer_id=customer_id
+ WHERE withdrawal_uuid=in_withdrawal_uuid AND login=in_login;
IF NOT FOUND THEN
out_no_op=TRUE;
RETURN;
@@ -687,6 +703,11 @@ IF NOT FOUND THEN
RETURN;
END IF;
+-- Check 2FA
+IF out_tan_required THEN
+ RETURN;
+END IF;
+
SELECT -- not checking for accounts existence, as it was done above.
transfer.out_balance_insufficient,
out_credit_row_id
@@ -720,19 +741,19 @@ COMMENT ON FUNCTION confirm_taler_withdrawal
IS 'Set a withdrawal operation as confirmed and wire the funds to the exchange.';
CREATE FUNCTION bank_wire_transfer(
- IN in_creditor_account_id BIGINT,
- IN in_debtor_account_id BIGINT,
+ IN in_creditor_account_id INT8,
+ IN in_debtor_account_id INT8,
IN in_subject TEXT,
IN in_amount taler_amount,
- IN in_transaction_date BIGINT, -- GNUnet microseconds.
+ IN in_transaction_date INT8,
IN in_account_servicer_reference TEXT,
IN in_payment_information_id TEXT,
IN in_end_to_end_id TEXT,
-- Error status
OUT out_balance_insufficient BOOLEAN,
-- Success return
- OUT out_credit_row_id BIGINT,
- OUT out_debit_row_id BIGINT
+ OUT out_credit_row_id INT8,
+ OUT out_debit_row_id INT8
)
LANGUAGE plpgsql AS $$
DECLARE
@@ -941,7 +962,7 @@ PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || in_creditor_account_
END $$;
CREATE FUNCTION cashin(
- IN in_now_date BIGINT,
+ IN in_now_date INT8,
IN in_reserve_pub BYTEA,
IN in_amount taler_amount,
IN in_subject TEXT,
@@ -954,9 +975,9 @@ CREATE FUNCTION cashin(
LANGUAGE plpgsql AS $$
DECLARE
converted_amount taler_amount;
- admin_account_id BIGINT;
- exchange_account_id BIGINT;
- tx_row_id BIGINT;
+ admin_account_id INT8;
+ exchange_account_id INT8;
+ tx_row_id INT8;
BEGIN
-- TODO check reserve_pub reuse ?
@@ -1020,32 +1041,29 @@ COMMENT ON FUNCTION cashin IS 'Perform a cashin operation';
CREATE FUNCTION cashout_create(
- IN in_account_username TEXT,
+ IN in_login TEXT,
IN in_request_uid BYTEA,
IN in_amount_debit taler_amount,
IN in_amount_credit taler_amount,
IN in_subject TEXT,
IN in_now_date INT8,
- IN in_tan_channel tan_enum,
- IN in_tan_code TEXT,
- IN in_retry_counter INT4,
- IN in_validity_period INT8,
+ IN in_is_tan BOOLEAN,
-- Error status
OUT out_bad_conversion BOOLEAN,
OUT out_account_not_found BOOLEAN,
OUT out_account_is_exchange BOOLEAN,
- OUT out_missing_tan_info BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
OUT out_request_uid_reuse BOOLEAN,
+ OUT out_no_cashout_payto BOOLEAN,
+ OUT out_tan_required BOOLEAN,
-- Success return
- OUT out_cashout_id BIGINT,
- OUT out_tan_info TEXT,
- OUT out_tan_code TEXT
+ OUT out_cashout_id INT8
)
LANGUAGE plpgsql AS $$
DECLARE
-account_id BIGINT;
-challenge_id BIGINT;
+account_id INT8;
+admin_account_id INT8;
+tx_id INT8;
BEGIN
-- check conversion
SELECT too_small OR no_config OR in_amount_credit!=converted INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text);
@@ -1053,152 +1071,48 @@ IF out_bad_conversion THEN
RETURN;
END IF;
--- check account exists and has appropriate tan info
+-- Check account exists, has all info and if 2FA is required
SELECT
- bank_account_id, is_taler_exchange,
- CASE
- WHEN in_tan_channel = 'sms' THEN phone
- WHEN in_tan_channel = 'email' THEN email
- END
- INTO account_id, out_account_is_exchange, out_tan_info
+ bank_account_id, is_taler_exchange, cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL)
+ INTO account_id, out_account_is_exchange, out_no_cashout_payto, out_tan_required
FROM bank_accounts
JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id
- WHERE login=in_account_username;
+ WHERE login=in_login;
IF NOT FOUND THEN
out_account_not_found=TRUE;
RETURN;
-ELSIF out_account_is_exchange THEN
- RETURN;
-ELSIF out_tan_info IS NULL THEN
- out_missing_tan_info=TRUE;
+ELSIF out_account_is_exchange OR out_no_cashout_payto THEN
RETURN;
END IF;
--- check enough funds
-SELECT account_balance_is_sufficient(account_id, in_amount_debit) INTO out_balance_insufficient;
-IF out_balance_insufficient THEN
- RETURN;
-END IF;
+-- Retrieve admin account id
+SELECT bank_account_id
+ INTO admin_account_id
+ FROM bank_accounts
+ JOIN customers
+ ON customer_id=owning_customer_id
+ WHERE login = 'admin';
-- Check for idempotence and conflict
SELECT (amount_debit != in_amount_debit
OR subject != in_subject
OR bank_account != account_id)
- , challenge, cashout_id
- INTO out_request_uid_reuse, challenge_id, out_cashout_id
+ , cashout_id
+ INTO out_request_uid_reuse, out_cashout_id
FROM cashout_operations
WHERE request_uid = in_request_uid;
-
-IF NOT found THEN
- -- New cashout
- out_tan_code = in_tan_code;
-
- -- Create challenge
- SELECT challenge_create(in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO challenge_id;
-
- -- Create cashout operation
- INSERT INTO cashout_operations (
- request_uid
- ,amount_debit
- ,amount_credit
- ,subject
- ,creation_time
- ,bank_account
- ,challenge
- ) VALUES (
- in_request_uid
- ,in_amount_debit
- ,in_amount_credit
- ,in_subject
- ,in_now_date
- ,account_id
- ,challenge_id
- ) RETURNING cashout_id INTO out_cashout_id;
-ELSE -- Already exist, check challenge retransmission
- SELECT challenge_resend(challenge_id, in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO out_tan_code;
-END IF;
-END $$;
-
-CREATE FUNCTION cashout_confirm(
- IN in_cashout_id BIGINT,
- IN in_login TEXT,
- IN in_tan_code TEXT,
- IN in_now_date BIGINT,
- OUT out_no_op BOOLEAN,
- OUT out_bad_conversion BOOLEAN,
- OUT out_bad_code BOOLEAN,
- OUT out_balance_insufficient BOOLEAN,
- OUT out_aborted BOOLEAN,
- OUT out_no_retry BOOLEAN,
- OUT out_no_cashout_payto BOOLEAN
-)
-LANGUAGE plpgsql as $$
-DECLARE
- wallet_account_id BIGINT;
- admin_account_id BIGINT;
- already_confirmed BOOLEAN;
- subject_local TEXT;
- amount_debit_local taler_amount;
- amount_credit_local taler_amount;
- challenge_id BIGINT;
- tx_id BIGINT;
-BEGIN
--- Retrieve cashout operation info
-SELECT
- local_transaction IS NOT NULL,
- aborted, subject,
- bank_account, challenge,
- (amount_debit).val, (amount_debit).frac,
- (amount_credit).val, (amount_credit).frac,
- cashout_payto IS NULL
- INTO
- already_confirmed,
- out_aborted, subject_local,
- wallet_account_id, challenge_id,
- amount_debit_local.val, amount_debit_local.frac,
- amount_credit_local.val, amount_credit_local.frac,
- out_no_cashout_payto
- FROM cashout_operations
- JOIN bank_accounts ON bank_account_id=bank_account
- JOIN customers ON customer_id=owning_customer_id
- WHERE cashout_id=in_cashout_id AND login=in_login;
-IF NOT FOUND THEN
- out_no_op=TRUE;
- RETURN;
-ELSIF already_confirmed OR out_aborted OR out_no_cashout_payto THEN
- RETURN;
-END IF;
-
--- check conversion
-SELECT too_small OR no_config OR amount_credit_local!=converted INTO out_bad_conversion FROM conversion_to(amount_debit_local, 'cashout'::text);
-IF out_bad_conversion THEN
- RETURN;
-END IF;
-
--- check challenge
-SELECT NOT ok, no_retry
- INTO out_bad_code, out_no_retry
- FROM challenge_try(challenge_id, in_tan_code, in_now_date);
-IF out_bad_code OR out_no_retry THEN
+IF found OR out_request_uid_reuse OR out_tan_required THEN
RETURN;
END IF;
--- Retrieve admin account id
-SELECT bank_account_id
- INTO admin_account_id
- FROM bank_accounts
- JOIN customers
- ON customer_id=owning_customer_id
- WHERE login = 'admin';
-
-- Perform bank wire transfer
SELECT transfer.out_balance_insufficient, out_debit_row_id
INTO out_balance_insufficient, tx_id
FROM bank_wire_transfer(
admin_account_id,
- wallet_account_id,
- subject_local,
- amount_debit_local,
+ account_id,
+ in_subject,
+ in_amount_debit,
in_now_date,
NULL,
NULL,
@@ -1208,72 +1122,114 @@ IF out_balance_insufficient THEN
RETURN;
END IF;
--- Confirm operation
-UPDATE cashout_operations
- SET local_transaction = tx_id
- WHERE cashout_id=in_cashout_id;
+-- Create cashout operation
+INSERT INTO cashout_operations (
+ request_uid
+ ,amount_debit
+ ,amount_credit
+ ,creation_time
+ ,bank_account
+ ,subject
+ ,local_transaction
+) VALUES (
+ in_request_uid
+ ,in_amount_debit
+ ,in_amount_credit
+ ,in_now_date
+ ,account_id
+ ,in_subject
+ ,tx_id
+) RETURNING cashout_id INTO out_cashout_id;
-- update stats
-CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_debit_local, amount_credit_local);
+CALL stats_register_payment('cashout', now()::TIMESTAMP, in_amount_debit, in_amount_credit);
END $$;
-CREATE FUNCTION challenge_create (
+CREATE FUNCTION tan_challenge_create (
+ IN in_body TEXT,
+ IN in_op op_enum,
IN in_code TEXT,
IN in_now_date INT8,
IN in_validity_period INT8,
IN in_retry_counter INT4,
- OUT out_challenge_id BIGINT
+ IN in_login TEXT,
+ IN in_tan_channel tan_enum,
+ IN in_tan_info TEXT,
+ OUT out_challenge_id INT8
)
-LANGUAGE sql AS $$
- INSERT INTO challenges (
- code,
- creation_date,
- expiration_date,
- retry_counter
- ) VALUES (
- in_code,
- in_now_date,
- in_now_date + in_validity_period,
- in_retry_counter
- ) RETURNING challenge_id
-$$;
-COMMENT ON FUNCTION challenge_create IS 'Create a new challenge, return the generated id';
-
-CREATE FUNCTION challenge_mark_sent (
- IN in_challenge_id BIGINT,
- IN in_now_date INT8,
- IN in_retransmission_period INT8
-) RETURNS void
-LANGUAGE sql AS $$
- UPDATE challenges SET
- retransmission_date = in_now_date + in_retransmission_period
- WHERE challenge_id = in_challenge_id;
-$$;
-COMMENT ON FUNCTION challenge_create IS 'Register a challenge as successfully sent';
+LANGUAGE plpgsql as $$
+DECLARE
+account_id INT8;
+BEGIN
+-- Retreive account id
+SELECT customer_id INTO account_id FROM customers WHERE login = in_login;
+-- 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_now_date,
+ in_now_date + 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 challenge_resend (
- IN in_challenge_id BIGINT,
- IN in_code TEXT, -- New code to use if the old code expired
+CREATE FUNCTION tan_challenge_send (
+ IN in_challenge_id INT8,
+ IN in_login TEXT,
+ IN in_code TEXT, -- New code to use if the old code expired
IN in_now_date INT8,
IN in_validity_period INT8,
IN in_retry_counter INT4,
- OUT out_tan_code TEXT -- Code to send, NULL if nothing should be sent
+ -- Error status
+ OUT out_no_op BOOLEAN,
+ -- Success return
+ OUT out_tan_code TEXT, -- TAN code to send, NULL if nothing should be sent
+ OUT out_tan_channel tan_enum, -- TAN channel to use, NULL if nothing should be sent
+ OUT out_tan_info TEXT -- TAN info to use, NULL if nothing should be sent
)
LANGUAGE plpgsql as $$
DECLARE
+account_id INT8;
expired BOOLEAN;
retransmit BOOLEAN;
BEGIN
+-- Retreive 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 login = in_login;
+
-- Recover expiration date
SELECT
(in_now_date >= expiration_date OR retry_counter <= 0) AND confirmation_date IS NULL
,in_now_date >= retransmission_date AND confirmation_date IS NULL
- ,code
-INTO expired, retransmit, out_tan_code
-FROM challenges WHERE challenge_id = in_challenge_id;
+ ,code, COALESCE(tan_channel, out_tan_channel), COALESCE(tan_info, out_tan_info)
+INTO expired, retransmit, out_tan_code, out_tan_channel, out_tan_info
+FROM tan_challenges WHERE challenge_id = in_challenge_id AND customer = account_id;
+IF NOT FOUND THEN
+ out_no_op = true;
+ RETURN;
+END IF;
IF expired THEN
- UPDATE challenges SET
+ UPDATE tan_challenges SET
code = in_code
,expiration_date = in_now_date + in_validity_period
,retry_counter = in_retry_counter
@@ -1283,40 +1239,82 @@ ELSIF NOT retransmit THEN
out_tan_code = NULL;
END IF;
END $$;
-COMMENT ON FUNCTION challenge_resend IS 'Get the challenge code to send, return NULL if nothing should be sent';
+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_now_date INT8,
+ IN in_retransmission_period INT8
+) RETURNS void
+LANGUAGE sql AS $$
+ UPDATE tan_challenges SET
+ retransmission_date = in_now_date + in_retransmission_period
+ WHERE challenge_id = in_challenge_id;
+$$;
+COMMENT ON FUNCTION tan_challenge_mark_sent IS 'Register a challenge as successfully sent';
-CREATE FUNCTION challenge_try (
- IN in_challenge_id BIGINT,
+CREATE FUNCTION tan_challenge_try (
+ IN in_challenge_id INT8,
+ IN in_login TEXT,
IN in_code TEXT,
- IN in_now_date INT8,
- OUT ok BOOLEAN,
- OUT no_retry BOOLEAN
+ IN in_now_date INT8,
+ -- Error status
+ OUT out_ok BOOLEAN,
+ OUT out_no_op BOOLEAN,
+ OUT out_no_retry BOOLEAN,
+ OUT out_expired BOOLEAN,
+ -- Success return
+ OUT out_op op_enum,
+ OUT out_body TEXT,
+ OUT out_channel tan_enum,
+ OUT out_info TEXT
)
-LANGUAGE sql as $$
- UPDATE challenges SET
- confirmation_date = CASE
- WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = in_code) THEN in_now_date
- ELSE confirmation_date
- END,
- retry_counter = retry_counter - 1
- WHERE challenge_id = in_challenge_id
- RETURNING confirmation_date IS NOT NULL, retry_counter < 0 AND confirmation_date IS NULL;
-$$;
-COMMENT ON FUNCTION challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed';
+LANGUAGE plpgsql as $$
+DECLARE
+account_id INT8;
+BEGIN
+-- Retreive account id
+SELECT customer_id INTO account_id FROM customers WHERE login = in_login;
+-- Check challenge
+UPDATE tan_challenges SET
+ confirmation_date = CASE
+ WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = in_code) THEN in_now_date
+ ELSE confirmation_date
+ END,
+ retry_counter = retry_counter - 1
+WHERE challenge_id = in_challenge_id AND customer = account_id
+RETURNING
+ confirmation_date IS NOT NULL,
+ retry_counter <= 0 AND confirmation_date IS NULL,
+ in_now_date >= expiration_date AND confirmation_date IS NULL
+INTO out_ok, out_no_retry, out_expired;
+IF NOT FOUND THEN
+ out_no_op = true;
+ RETURN;
+ELSIF NOT out_ok OR out_no_retry OR out_expired THEN
+ RETURN;
+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;
+END $$;
+COMMENT ON FUNCTION tan_challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed';
CREATE FUNCTION stats_get_frame(
IN now TIMESTAMP,
IN in_timeframe stat_timeframe_enum,
IN which INTEGER,
- OUT cashin_count BIGINT,
+ OUT cashin_count INT8,
OUT cashin_regional_volume taler_amount,
OUT cashin_fiat_volume taler_amount,
- OUT cashout_count BIGINT,
+ OUT cashout_count INT8,
OUT cashout_regional_volume taler_amount,
OUT cashout_fiat_volume taler_amount,
- OUT taler_in_count BIGINT,
+ OUT taler_in_count INT8,
OUT taler_in_volume taler_amount,
- OUT taler_out_count BIGINT,
+ OUT taler_out_count INT8,
OUT taler_out_volume taler_amount
)
LANGUAGE plpgsql AS $$
@@ -1469,7 +1467,7 @@ BEGIN
-- Extract product parts
result = (trunc(product_numeric / 100000000)::int8, (product_numeric % 100000000)::int4);
- IF (result.val > 1::bigint<<52) THEN
+ IF (result.val > 1::INT8<<52) THEN
RAISE EXCEPTION 'amount value overflowed';
END IF;
END $$;
@@ -1506,7 +1504,7 @@ BEGIN
-- Extract division parts
result = (trunc(fraction_numeric / 100000000)::int8, (fraction_numeric % 100000000)::int4);
- IF (result.val > 1::bigint<<52) THEN
+ IF (result.val > 1::INT8<<52) THEN
RAISE EXCEPTION 'amount value overflowed';
END IF;
END $$;
diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql
index 0bf1e506..37661112 100644
--- a/database-versioning/libeufin-conversion-setup.sql
+++ b/database-versioning/libeufin-conversion-setup.sql
@@ -5,7 +5,7 @@ CREATE OR REPLACE FUNCTION cashout_link()
RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
- now_date BIGINT;
+ now_date INT8;
payto_uri TEXT;
BEGIN
-- TODO should send to an exchange
@@ -42,7 +42,7 @@ CREATE OR REPLACE FUNCTION cashin_link()
RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
- now_date BIGINT;
+ now_date INT8;
local_amount libeufin_bank.taler_amount;
subject TEXT;
too_small BOOLEAN;
diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt
index e5766a61..13b665c0 100644
--- a/integration/test/IntegrationTest.kt
+++ b/integration/test/IntegrationTest.kt
@@ -174,13 +174,7 @@ class IntegrationTest {
"amount_debit" to amount
"amount_credit" to convert
}
- }.assertOkJson<CashoutPending> {
- val code = File("/tmp/tan-+99.txt").readText()
- client.post("http://0.0.0.0:8090/accounts/customer/cashouts/${it.cashout_id}/confirm") {
- basicAuth("customer", "password")
- json { "tan" to code }
- }.assertNoContent()
- }
+ }.assertOkJson<CashoutResponse>()
}
}
}
diff --git a/util/src/main/kotlin/TalerErrorCode.kt b/util/src/main/kotlin/TalerErrorCode.kt
index 8638dfc2..ff1e6af8 100644
--- a/util/src/main/kotlin/TalerErrorCode.kt
+++ b/util/src/main/kotlin/TalerErrorCode.kt
@@ -2650,7 +2650,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The backend lacks a wire transfer method configuration option for the given instance. Thus, this instance is unavailable (not findable for creating new orders).
+ * The merchant instance has no active bank accounts configured. However, at least one bank account must be available to create new orders.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2658,7 +2658,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The proposal had no timestamp and the backend failed to obtain the local time. Likely to be an internal error.
+ * The proposal had no timestamp and the merchant backend failed to obtain the current local time.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2666,7 +2666,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The order provided to the backend could not be parsed, some required fields were missing or ill-formed.
+ * The order provided to the backend could not be parsed; likely some required fields were missing or ill-formed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2674,7 +2674,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The backend encountered an error: the proposal already exists.
+ * A conflicting order (sharing the same order identifier) already exists at this merchant backend instance.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2682,7 +2682,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The request is invalid: the wire deadline is before the refund deadline.
+ * The order creation request is invalid because the given wire deadline is before the refund deadline.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2690,7 +2690,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The request is invalid: a delivery date was given, but it is in the past.
+ * The order creation request is invalid because the delivery date given is in the past.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2698,7 +2698,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The request is invalid: the wire deadline for the order would be "never".
+ * The order creation request is invalid because a wire deadline of "never" is not allowed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2706,7 +2706,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The request is invalid: a payment deadline was given, but it is in the past.
+ * The order ceration request is invalid because the given payment deadline is in the past.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2714,7 +2714,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The request is invalid: a refund deadline was given, but it is in the past.
+ * The order creation request is invalid because the given refund deadline is in the past.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2722,7 +2722,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The backend does not trust any exchange that would allow funds to be wired to any bank account of this instance using the selected wire method. Note that right now, we do not support the use of exchange bank accounts with mandatory currency conversion.
+ * The backend does not trust any exchange that would allow funds to be wired to any bank account of this instance using the wire method specified with the order. Note that right now, we do not support the use of exchange bank accounts with mandatory currency conversion.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2746,7 +2746,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The order provided to the backend could not be deleted, our offer is still valid and awaiting payment.
+ * The order provided to the backend could not be deleted, our offer is still valid and awaiting payment. Deletion may work later after the offer has expired if it remains unpaid.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2762,7 +2762,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it is too big to be paid back. In this second case, the fault stays on the business dept. side.
+ * The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it exceeds the original price paid by the customer.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2770,7 +2770,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The frontend gave an unpaid order id to issue the refund to.
+ * Only paid orders can be refunded, and the frontend specified an unpaid order to issue a refund for.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2778,7 +2778,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The refund delay was set to 0 and thus no refunds are allowed for this order.
+ * The refund delay was set to 0 and thus no refunds are ever allowed for this order.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -3498,6 +3498,30 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * The referenced challenge was not found.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_CHALLENGE_NOT_FOUND(5143),
+
+
+ /**
+ * The referenced challenge has expired.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_TAN_CHALLENGE_EXPIRED(5144),
+
+
+ /**
+ * A non-admin user has tried to create an account with 2fa.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_SET_TAN_CHANNEL(5145),
+
+
+ /**
* The sync service failed find the account in its database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).