From ea3ceef3740ce30400bc20c7aae09b25d3e0f0c3 Mon Sep 17 00:00:00 2001 From: Christian Grothoff Date: Thu, 7 Sep 2023 15:24:27 +0200 Subject: sandbox -> bank --- .../kotlin/tech/libeufin/sandbox/CircuitApi.kt | 841 ---------- .../tech/libeufin/sandbox/ConversionService.kt | 433 ----- .../src/main/kotlin/tech/libeufin/sandbox/DB.kt | 747 --------- .../main/kotlin/tech/libeufin/sandbox/Database.kt | 665 -------- .../tech/libeufin/sandbox/EbicsProtocolBackend.kt | 1436 ---------------- .../main/kotlin/tech/libeufin/sandbox/Helpers.kt | 472 ------ .../src/main/kotlin/tech/libeufin/sandbox/JSON.kt | 154 -- .../src/main/kotlin/tech/libeufin/sandbox/Main.kt | 1711 -------------------- .../tech/libeufin/sandbox/XMLEbicsConverter.kt | 70 - .../kotlin/tech/libeufin/sandbox/bankAccount.kt | 276 ---- 10 files changed, 6805 deletions(-) delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt (limited to 'sandbox/src/main/kotlin/tech/libeufin') diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt deleted file mode 100644 index 4d8d36d9..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt +++ /dev/null @@ -1,841 +0,0 @@ -package tech.libeufin.sandbox - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.server.application.* -import io.ktor.http.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.sandbox.CashoutOperationsTable.uuid -import tech.libeufin.util.* -import java.io.File -import java.io.InputStreamReader -import java.math.BigDecimal -import java.util.concurrent.TimeUnit -import kotlin.text.toByteArray - -// CIRCUIT API TYPES -/** - * This type is used by clients to ask the bank a cash-out - * estimate to show to the customer before they confirm the - * cash-out creation. - */ -data class CircuitCashoutEstimateRequest( - /** - * This is the amount that the customer will get deducted - * from their regio bank account to fuel the cash-out operation. - */ - val amount_debit: String -) -data class CircuitCashoutRequest( - val subject: String?, - val amount_debit: String, // As specified by the user via the SPA. - val amount_credit: String, // What actually to transfer after the rates. - /** - * The String type here allows more flexibility with regard to - * the supported TAN methods. This way, supported TAN methods - * can be specified via the configuration or when starting the - * bank. OTOH, catching unsupported TAN methods only via the - * 'enum' type would require to change the source code upon every - * change in the TAN policy. - */ - val tan_channel: String? -) -const val FIAT_CURRENCY = "CHF" // FIXME: make configurable. -// Configuration response: -data class ConfigResp( - val name: String = "circuit", - val version: String = PROTOCOL_VERSION_UNIFIED, - val ratios_and_fees: RatioAndFees, - val fiat_currency: String = FIAT_CURRENCY -) - -// After fixing #7527, the values held by this -// type must be read from the configuration. -data class RatioAndFees( - val buy_at_ratio: Float = 1F, - val sell_at_ratio: Float = 0.95F, - val buy_in_fee: Float = 0F, - val sell_out_fee: Float = 0F -) -val ratiosAndFees = RatioAndFees() - -// User registration request -data class CircuitAccountRequest( - val username: String, - val password: String, - val contact_data: CircuitContactData, - val name: String, - val cashout_address: String, // payto - val internal_iban: String? // Shall be "= null" ? -) -// User contact data to send the TAN. -data class CircuitContactData( - val email: String?, - val phone: String? -) - -data class CircuitAccountReconfiguration( - val contact_data: CircuitContactData, - val cashout_address: String?, - val name: String? = null -) - -data class AccountPasswordChange( - val new_password: String -) - -/** - * That doesn't belong to the Access API because it - * contains the cash-out address and the contact data. - */ -data class CircuitAccountInfo( - val username: String, - val iban: String, - val contact_data: CircuitContactData, - val name: String, - val cashout_address: String? -) - -data class CashoutOperationInfo( - val status: CashoutOperationStatus, - val amount_credit: String, - val amount_debit: String, - val subject: String, - val creation_time: Long, // milliseconds - val confirmation_time: Long?, // milliseconds - val tan_channel: SupportedTanChannels, - val account: String, - val cashout_address: String, - val ratios_and_fees: RatioAndFees -) - -data class CashoutConfirmation(val tan: String) - -// Validate phone number -fun checkPhoneNumber(phoneNumber: String): Boolean { - // From Taler TypeScript - // /^\+[0-9 ]*$/; - val regex = "^\\+[1-9][0-9]+$" - val R = Regex(regex) - return R.matches(phoneNumber) -} - -// Validate e-mail address -fun checkEmailAddress(emailAddress: String): Boolean { - // From Taler TypeScript: - // /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$" - val R = Regex(regex) - return R.matches(emailAddress) -} - -fun throwIfInstitutionalName(resourceName: String) { - if (resourceName == "bank" || resourceName == "admin") - throw forbidden("Can't operate on institutional resource '$resourceName'") -} - -fun generateCashoutSubject( - amountCredit: AmountWithCurrency, - amountDebit: AmountWithCurrency -): String { - return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" + - " to ${amountCredit.currency}:${amountCredit.amount}" -} - -/** - * By default, it takes the amount in the regional currency - * and applies ratio and fees to convert it to fiat. If the - * 'fromCredit' parameter is true, then it does the inverse - * operation: returns the regional amount that would lead to - * such fiat amount given in the 'amount' parameter. - */ -fun applyCashoutRatioAndFee( - amount: BigDecimal, - ratiosAndFees: RatioAndFees, - fromCredit: Boolean = false -): BigDecimal { - // Normal case, when the calculation starts from the regional amount. - if (!fromCredit) { - val maybeCashoutAmount = ((amount * ratiosAndFees.sell_at_ratio.toBigDecimal()) - - ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits() - // throws 500, since bank should not allow to get negative fiat amounts. - if (maybeCashoutAmount < BigDecimal.ZERO) { - logger.error("Cash-out operation caused a negative fiat output." + - " Regional amount was '$amount', cash-out ratio is '${ratiosAndFees.sell_at_ratio}," + - " cash-out fee is '${ratiosAndFees.sell_out_fee}''" - ) - throw internalServerError("Applying cash-out fees yielded negative fiat amount.") - } - return maybeCashoutAmount - } - // UI convenient case, when the calculation starts from the - // desired fiat amount that the user wants eventually be paid. - return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) / - ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits() -} - -/** - * NOTE: future versions take the supported TAN method from - * the configuration, or options passed when starting the bank. - */ -const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt" -enum class SupportedTanChannels { - SMS, - EMAIL, - FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location. -} -fun isTanChannelSupported(tanChannel: String): Boolean { - enumValues().forEach { - if (tanChannel.uppercase() == it.name) return true - } - return false -} - -var EMAIL_TAN_CMD: String? = null -var SMS_TAN_CMD: String? = null - -// Convenience class to collect TAN data. -private data class TanData( - val cmd: String, - val address: String, - val msg: String -) - -/** - * Runs the command and returns True/False if that succeeded/failed. - * A failed command causes "500 Internal Server Error" to be responded - * along a cash-out creation. 'address' is a phone number or a e-mail address, - * according to which TAN channel is used. 'message' carries the TAN. - * - * The caller is expected to manage the exceptions thrown by this function. - */ -fun runTanCommand(command: String, address: String, message: String): Boolean { - val prep = ProcessBuilder(command, address) - prep.redirectErrorStream(true) // merge STDOUT and STDERR - val proc = prep.start() - proc.outputStream.write(message.toByteArray()) - proc.outputStream.flush(); proc.outputStream.close() - var isSuccessful = false - // Wait the command to finish. - proc.waitFor(10L, TimeUnit.SECONDS) - // Check if timed out. Kill if so. - if (proc.isAlive) { - logger.error("TAN command '$command' timed out, killing it.") - proc.destroy() - // Check if exited gracefully. Kill forcibly if not. - proc.waitFor(5L, TimeUnit.SECONDS) - if (proc.isAlive) { - logger.error("TAN command '$command' didn't terminate after killing it. Try forcefully.") - proc.destroyForcibly() - } - } - // Check if successful. Switch the state if so. - if (proc.exitValue() == 0) isSuccessful = true - // Log STDOUT and STDERR if failed. - if (!isSuccessful) - logger.error(InputStreamReader(proc.inputStream).readText()) - return isSuccessful -} - -fun circuitApi(circuitRoute: Route) { - // Abort a cash-out operation. - circuitRoute.post("/cashouts/{uuid}/abort") { - call.request.basicAuth() // both admin and author allowed - val arg = call.expectUriComponent("uuid") - // Parse and check the UUID. - val maybeUuid = parseUuid(arg) - val maybeOperation = transaction { - CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() - } - if (maybeOperation == null) - throw notFound("Cash-out operation $uuid not found.") - if (maybeOperation.status == CashoutOperationStatus.CONFIRMED) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out operation '$uuid' was confirmed already." - ) - if (maybeOperation.status != CashoutOperationStatus.PENDING) - throw internalServerError("Found an unsupported cash-out operation state: ${maybeOperation.status}") - // Operation found and pending: delete from the database. - transaction { maybeOperation.delete() } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Confirm a cash-out operation - circuitRoute.post("/cashouts/{uuid}/confirm") { - val user = call.request.basicAuth() - // Exclude admin from this operation. - if (user == "admin" || user == "bank") - throw conflict("Institutional user '$user' shouldn't confirm any cash-out.") - // Get the operation identifier. - val operationUuid = parseUuid(call.expectUriComponent("uuid")) - val op = transaction { - CashoutOperationEntity.find { - uuid eq operationUuid - }.firstOrNull() - } - // 404 if the operation is not found. - if (op == null) - throw notFound("Cash-out operation $operationUuid not found") - /** - * Check the TAN. Give precedence to the TAN found - * in the environment, for testing purposes. If that's - * not found, then check with the actual TAN found in - * the database. - */ - val req = call.receive() - val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN") - if (maybeTanFromEnv != null) - logger.warn("TAN being read from the environment. Assuming tests are being run") - val checkTan = maybeTanFromEnv ?: op.tan - if (req.tan != checkTan) - throw forbidden("The confirmation of '${op.uuid}' has a wrong TAN '${req.tan}'") - /** - * Correct TAN. Wire the funds to the admin's bank account. After - * this step, the conversion monitor should detect this payment and - * soon initiate the final transfer towards the user fiat bank account. - * NOTE: the funds availability got already checked when this operation - * was created. On top of that, the 'wireTransfer()' helper does also - * check for funds availability. */ - val customer = maybeGetCustomer(user ?: throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "This endpoint isn't served when the authentication is disabled." - )) - transaction { - if (op.cashoutAddress != customer?.cashout_address) throw conflict( - "Inconsistent cash-out address: ${op.cashoutAddress} vs ${customer?.cashout_address}" - ) - // 412 if the operation got already confirmed. - if (op.status == CashoutOperationStatus.CONFIRMED) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out operation $operationUuid was already confirmed." - ) - wireTransfer( - debitAccount = op.account, - creditAccount = "admin", - subject = op.subject, - amount = op.amountDebit - ) - op.status = CashoutOperationStatus.CONFIRMED - op.confirmationTime = getSystemTimeNow().toInstant().toEpochMilli() - // TODO(signal this payment over LIBEUFIN_REGIO_INCOMING) - } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Retrieve the status of a cash-out operation. - circuitRoute.get("/cashouts/{uuid}") { - call.request.basicAuth() // both admin and author - val operationUuid = call.expectUriComponent("uuid") - // Parse and check the UUID. - val maybeUuid = parseUuid(operationUuid) - // Get the operation from the database. - val maybeOperation = transaction { - CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() - } - if (maybeOperation == null) - throw notFound("Cash-out operation $operationUuid not found.") - val ret = CashoutOperationInfo( - amount_credit = maybeOperation.amountCredit, - amount_debit = maybeOperation.amountDebit, - subject = maybeOperation.subject, - status = maybeOperation.status, - creation_time = maybeOperation.creationTime, - confirmation_time = maybeOperation.confirmationTime, - tan_channel = maybeOperation.tanChannel, - account = maybeOperation.account, - cashout_address = maybeOperation.cashoutAddress, - ratios_and_fees = RatioAndFees( - buy_in_fee = maybeOperation.buyInFee.toFloat(), - buy_at_ratio = maybeOperation.buyAtRatio.toFloat(), - sell_out_fee = maybeOperation.sellOutFee.toFloat(), - sell_at_ratio = maybeOperation.sellAtRatio.toFloat() - ) - ) - call.respond(ret) - return@get - } - // Gets the list of all the cash-out operations, - // or those belonging to the account given as a parameter. - circuitRoute.get("/cashouts") { - val user = call.request.basicAuth() - val whichAccount = call.request.queryParameters["account"] - /** - * Only admin's allowed to omit the target account (= get - * all the accounts) or to check other customers cash-out - * operations. - */ - if (user != "admin" && whichAccount != user) throw forbidden( - "Ordinary users can only request their own account" - ) - /** - * At this point, the client has the rights over the account(s) - * whose operations are to be returned. Double-checking that - * Admin doesn't ask its own cash-outs, since that's not supported. - */ - if (whichAccount == "admin") throw badRequest("Cash-out for admin is not supported") - - // Preparing the response. - val node = jacksonObjectMapper().createObjectNode() - val maybeArray = node.putArray("cashouts") - - if (whichAccount == null) { // no target account, return all the cash-outs - transaction { - CashoutOperationEntity.all().forEach { - maybeArray.add(it.uuid.toString()) - } - } - } else { // do filter on the target account. - transaction { - CashoutOperationEntity.find { - CashoutOperationsTable.account eq whichAccount - }.forEach { - maybeArray.add(it.uuid.toString()) - } - } - } - if (maybeArray.size() == 0) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond(node) - return@get - } - circuitRoute.get("/cashouts/estimates") { - call.request.basicAuth() - val demobank = ensureDemobank(call) - // Optionally parsing param 'amount_debit' into number and checking its currency - val maybeAmountDebit: String? = call.request.queryParameters["amount_debit"] - val amountDebit: BigDecimal? = if (maybeAmountDebit != null) { - val amount = parseAmount(maybeAmountDebit) - if (amount.currency != demobank.config.currency) throw badRequest( - "parameter 'amount_debit' has the wrong currency: ${amount.currency}" - ) - try { amount.amount.toBigDecimal() } catch (e: Exception) { - throw badRequest("Cannot extract a number from 'amount_debit'") - } - } else null - // Optionally parsing param 'amount_credit' into number and checking its currency - val maybeAmountCredit: String? = call.request.queryParameters["amount_credit"] - val amountCredit: BigDecimal? = if (maybeAmountCredit != null) { - val amount = parseAmount(maybeAmountCredit) - if (amount.currency != FIAT_CURRENCY) throw badRequest( - "parameter 'amount_credit' has the wrong currency: ${amount.currency}" - ) - try { amount.amount.toBigDecimal() } catch (e: Exception) { - throw badRequest("Cannot extract a number from 'amount_credit'") - } - } else null - val respAmountCredit = if (amountDebit != null) { - val estimate = applyCashoutRatioAndFee(amountDebit, ratiosAndFees) - if (amountCredit != null && estimate != amountCredit) throw badRequest( - "Wrong calculation found in 'amount_credit', bank estimates: $estimate" - ) - estimate - } else null - if (amountDebit == null && amountCredit == null) throw badRequest( - "Both 'amount_credit' and 'amount_debit' are missing" - ) - val respAmountDebit = if (amountCredit != null) { - val estimate = applyCashoutRatioAndFee( - amountCredit, - ratiosAndFees, - fromCredit = true - ) - if (amountDebit != null && estimate != amountDebit) throw badRequest( - "Wrong calculation found in 'amount_credit', bank estimates: $estimate" - ) - estimate - } else null - call.respond(object { - val amount_credit = "$FIAT_CURRENCY:$respAmountCredit" - val amount_debit = "${demobank.config.currency}:$respAmountDebit" - }) - return@get - } - - // Create a cash-out operation. - circuitRoute.post("/cashouts") { - val user = call.request.basicAuth() - if (user == "admin" || user == "bank") throw forbidden("$user can't cash-out.") - // No suitable default user, when the authentication is disabled. - if (user == null) throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "This endpoint isn't served when the authentication is disabled." - ) - val req = call.receive() - - // validate amounts: well-formed and supported currency. - val amountDebit = parseAmount(req.amount_debit) // amount before rates. - val amountCredit = parseAmount(req.amount_credit) // amount after rates, as expected by the client - val demobank = ensureDemobank(call) - // Currency check of the cash-out's circuit part. - if (amountDebit.currency != demobank.config.currency) - throw badRequest("'${req::amount_debit.name}' (${req.amount_debit})" + - " doesn't match the regional currency (${demobank.config.currency})" - ) - // Currency check of the cash-out's fiat part. - if (amountCredit.currency != FIAT_CURRENCY) - throw badRequest("'${req::amount_credit.name}' (${req.amount_credit})" + - " doesn't match the fiat currency ($FIAT_CURRENCY)." - ) - // check if TAN is supported. Default to SMS, if that's missing. - val tanChannel = req.tan_channel?.uppercase() ?: SupportedTanChannels.SMS.name - if (!isTanChannelSupported(tanChannel)) - throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "TAN channel '$tanChannel' not supported." - ) - // check if the user contact data would allow the TAN channel. - val customer: DemobankCustomerEntity? = maybeGetCustomer(username = user) - if (customer == null) throw internalServerError( - "Customer profile '$user' not found after authenticating it." - ) - if (customer.cashout_address == null) throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out address not found. Did the user register via Circuit API?" - ) - if ((tanChannel == SupportedTanChannels.EMAIL.name) && (customer.email == null)) - throw conflict("E-mail address not found for '$user'. Can't send the TAN") - if ((tanChannel == SupportedTanChannels.SMS.name) && (customer.phone == null)) - throw conflict("Phone number not found for '$user'. Can't send the TAN") - // check rates correctness - val amountDebitAsNumber = BigDecimal(amountDebit.amount) - val expectedAmountCredit = applyCashoutRatioAndFee(amountDebitAsNumber, ratiosAndFees) - val amountCreditAsNumber = BigDecimal(amountCredit.amount).roundToTwoDigits() - if (expectedAmountCredit != amountCreditAsNumber) { - throw badRequest("Rates application are incorrect." + - " The expected amount to credit is: ${expectedAmountCredit}," + - " but ${amountCredit.amount} was specified.") - } - // check that the balance is sufficient - val balance = getBalance( - user, - demobank.name - ) - val balanceCheck = balance - amountDebitAsNumber - if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal(demobank.config.usersDebtLimit)) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out not possible due to insufficient funds. Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}" - ) - // generate a subject if that's missing - val cashoutSubject = req.subject ?: generateCashoutSubject( - amountCredit = amountCredit, - amountDebit = amountDebit - ) - val op = transaction { - CashoutOperationEntity.new { - this.amountDebit = req.amount_debit - this.amountCredit = req.amount_credit - this.buyAtRatio = ratiosAndFees.buy_at_ratio.toString() - this.buyInFee = ratiosAndFees.buy_in_fee.toString() - this.sellAtRatio = ratiosAndFees.sell_at_ratio.toString() - this.sellOutFee = ratiosAndFees.sell_out_fee.toString() - this.subject = cashoutSubject - this.creationTime = getSystemTimeNow().toInstant().toEpochMilli() - this.tanChannel = SupportedTanChannels.valueOf(tanChannel) - this.account = user - this.tan = getRandomString(5) - this.cashoutAddress = customer.cashout_address ?: throw internalServerError( - "Cash-out address for '$user' not found, after previous check succeeded" - ) - } - } - when (tanChannel) { - SupportedTanChannels.EMAIL.name -> { - val isSuccessful = try { - runTanCommand( - command = EMAIL_TAN_CMD ?: throw internalServerError( - "E-mail TAN supported but the command" + - " was not found. See the --email-tan option from 'serve'" - ), - address = customer.email ?: throw internalServerError( - "Customer has no e-mail address, but previous check should" + - " have detected it!" - ), - message = op.tan - ) - } catch (e: Exception) { - logger.error("Sending the e-mail TAN to ${customer.email} was impossible." + - " Reason: ${e.message}") - throw internalServerError("Could not send the e-mail TAN.") - } - if (!isSuccessful) - throw internalServerError("E-mail TAN command failed.") - } - SupportedTanChannels.SMS.name -> { - val isSuccessful = try { - runTanCommand( - command = SMS_TAN_CMD ?: throw internalServerError( - "SMS TAN supported but the command" + - " was not found. See the --sms-tan option from 'serve'" - ), - address = customer.phone ?: throw internalServerError( - "Customer has no phone number, but previous check should" + - " have detected it!" - - ), - message = op.tan - ) - - } catch (e: Exception) { - logger.error("Sending the SMS TAN to ${customer.phone} was impossible." + - " Reason: ${e.message}") - throw internalServerError("Could not send the SMS TAN.") - } - if (!isSuccessful) - throw internalServerError("SMS TAN command failed.") - } - SupportedTanChannels.FILE.name -> { - try { - File(LIBEUFIN_TAN_TMP_FILE).writeText(op.tan) - } catch (e: Exception) { - logger.error("Could not write to $LIBEUFIN_TAN_TMP_FILE. Reason: ${e.message}") - throw internalServerError("File TAN failed.") - } - } - else -> - throw internalServerError("The bank tried an unsupported TAN channel: $tanChannel.") - } - call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid}) - return@post - } - // Get Circuit-relevant account data. - circuitRoute.get("/accounts/{resourceName}") { - val username = call.request.basicAuth() - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - if (!allowOwnerOrAdmin(username, resourceName)) throw forbidden( - "User $username has no rights over $resourceName" - ) - val customer = getCustomer(resourceName) - /** - * CUSTOMER AND BANK ACCOUNT INVARIANT. - * - * After having found a 'customer' associated with the resourceName - * - see previous line -, the bank must ensure that a 'bank account' - * exist under the same resourceName. If that fails, the bank broke the - * invariant and should respond 500. - */ - val bankAccount = getBankAccountFromLabel(resourceName, withBankFault = true) - /** - * Throwing when name or cash-out address aren't found ensures - * that the customer was indeed added via the Circuit API, as opposed - * to the Access API. - */ - call.respond(CircuitAccountInfo( - username = customer.username, - name = customer.name ?: throw internalServerError( - "Account '$resourceName' was found without owner's name." - ), - cashout_address = customer.cashout_address, - contact_data = CircuitContactData( - email = customer.email, - phone = customer.phone - ), - iban = bankAccount.iban - )) - return@get - } - - // Get summary of all the accounts. - circuitRoute.get("/accounts") { - call.request.basicAuth(onlyAdmin = true) - val maybeFilter: String? = call.request.queryParameters["filter"] - /** - * Equip the given filter with left and right catch-all wildcards, - * otherwise use one catch-all wildcard. - */ - val filter = if (maybeFilter != null) { - "%${maybeFilter}%" - } else "%" - val customers = mutableListOf() - val demobank = ensureDemobank(call) - transaction { - /** - * This block builds the DB query so that IF the %-wildcard was - * given, then BOTH name and name-less accounts are returned. - */ - val query: Op = SqlExpressionBuilder.run { - val like = DemobankCustomersTable.name.like(filter) - /** - * This IF statement is needed because Postgres would NOT - * match a null column even with the %-wildcard. - */ - if (filter == "%") { - return@run like.or(DemobankCustomersTable.name.isNull()) - } - return@run like - } - DemobankCustomerEntity.find { query }.forEach { - customers.add(object { - val username = it.username - val name = it.name - val balance = getBalanceForJson( - getBalance(it.username, demobank.name), - demobank.config.currency - ) - val debitThreshold = getMaxDebitForUser( - it.username, - demobank.name - ) - }) - } - StdOutSqlLogger - } - if (customers.size == 0) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond(object {val customers = customers}) - return@get - } - - // Change password. - circuitRoute.patch("/accounts/{customerUsername}/auth") { - val username = call.request.basicAuth() - val customerUsername = call.expectUriComponent("customerUsername") - throwIfInstitutionalName(customerUsername) - if (!allowOwnerOrAdmin(username, customerUsername)) throw forbidden( - "User $username has no rights over $customerUsername" - ) - // Flow here means admin or username have the rights for this operation. - val req = call.receive() - /** - * The resource/customer might still not exist, in case admin has requested. - * On the other hand, when ordinary customers request, their existence is checked - * along the basic authentication check. - */ - transaction { - val customer = getCustomer(customerUsername) // throws 404, if not found. - customer.passwordHash = CryptoUtil.hashpw(req.new_password) - } - call.respond(HttpStatusCode.NoContent) - return@patch - } - // Change account (mostly contact) data. - circuitRoute.patch("/accounts/{resourceName}") { - val username = call.request.basicAuth() - if (username == null) - throw internalServerError("Authentication disabled, don't have a default for this request.") - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - if(!allowOwnerOrAdmin(username, resourceName)) throw forbidden( - "User $username has no rights over $resourceName" - ) - // account found and authentication succeeded - val req = call.receive() - // Only admin's allowed to change the legal name - if (req.name != null && username != "admin") throw forbidden( - "Only admin can change the user legal name" - ) - if ((req.contact_data.email != null) && (!checkEmailAddress(req.contact_data.email))) - throw badRequest("Invalid e-mail address: ${req.contact_data.email}") - if ((req.contact_data.phone != null) && (!checkPhoneNumber(req.contact_data.phone))) - throw badRequest("Invalid phone number: ${req.contact_data.phone}") - try { if (req.cashout_address != null) parsePayto(req.cashout_address) } - catch (e: InvalidPaytoError) { - throw badRequest("Invalid cash-out address: ${req.cashout_address}") - } - transaction { - val user = getCustomer(resourceName) - user.email = req.contact_data.email - user.phone = req.contact_data.phone - user.cashout_address = req.cashout_address - } - call.respond(HttpStatusCode.NoContent) - return@patch - } - // Create new account. - circuitRoute.post("/accounts") { - call.request.basicAuth(onlyAdmin = true) - val req = call.receive() - // Validity and availability check on the input data. - if (req.contact_data.email != null) { - if (!checkEmailAddress(req.contact_data.email)) - throw badRequest("Invalid e-mail address: ${req.contact_data.email}. Won't register") - val maybeEmailConflict = transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.email eq req.contact_data.email - }.firstOrNull() - } - // Warning since two individuals claimed one same e-mail address. - if (maybeEmailConflict != null) - throw conflict("Won't register user ${req.username}: e-mail conflict on ${req.contact_data.email}") - } - if (req.contact_data.phone != null) { - if (!checkPhoneNumber(req.contact_data.phone)) - throw badRequest("Invalid phone number: ${req.contact_data.phone}. Won't register") - - val maybePhoneConflict = transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.phone eq req.contact_data.phone - }.firstOrNull() - } - // Warning since two individuals claimed one same phone number. - if (maybePhoneConflict != null) - throw conflict("Won't register user ${req.username}: phone conflict on ${req.contact_data.phone}") - } - /** - * Check that cash-out address parses. IBAN is not - * check-summed in this version; the cash-out operation - * just fails for invalid IBANs and the user has then - * the chance to update their IBAN. - */ - try { - parsePayto(req.cashout_address) - } - catch (e: InvalidPaytoError) { - throw badRequest("Won't register account ${req.username}: invalid cash-out address: ${req.cashout_address}") - } - transaction { - val newAccount = insertNewAccount( - username = req.username, - password = req.password, - name = req.name, - iban = req.internal_iban, - demobank = ensureDemobank(call).name - ) - newAccount.customer.phone = req.contact_data.phone - newAccount.customer.email = req.contact_data.email - newAccount.customer.cashout_address = req.cashout_address - } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Get (conversion rates via) config values. - circuitRoute.get("/config") { - call.respond(ConfigResp(ratios_and_fees = ratiosAndFees)) - return@get - } - // Only Admin and only when balance is zero. - circuitRoute.delete("/accounts/{resourceName}") { - call.request.basicAuth(onlyAdmin = true) - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - val customer = getCustomer(resourceName) - val bankAccount = getBankAccountFromLabel( - resourceName, - withBankFault = true // See comment "CUSTOMER AND BANK ACCOUNT INVARIANT". - ) - val balance: BigDecimal = getBalance(bankAccount) - if (!isAmountZero(balance)) { - logger.error("Account $resourceName has $balance balance. Won't delete it") - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Account $resourceName doesn't have zero balance. Won't delete it" - ) - } - transaction { - bankAccount.delete() - customer.delete() - } - call.respond(HttpStatusCode.NoContent) - return@delete - } -} \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt deleted file mode 100644 index c760a2b1..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt +++ /dev/null @@ -1,433 +0,0 @@ -package tech.libeufin.sandbox - -import CamtBankAccountEntry -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.math.BigDecimal -import kotlin.system.exitProcess - -/** - * This file contains the logic for downloading/submitting incoming/outgoing - * fiat transactions to Nexus. It needs the following values for operating. - * - * 1. Nexus URL. - * 2. Credentials to authenticate at Nexus JSON API. - * 3. Long-polling interval. - * 4. Frequency of the download loop. - * - * Notes: - * - * 1. The account to credit on incoming transactions is ALWAYS "admin". - * 2. The time to submit a new payment is as soon as "admin" receives one - * incoming regional payment. - * 3. At this time, Nexus does NOT offer long polling when it serves the - * transactions via its JSON API. => Fixed. - * 4. At this time, Nexus does NOT offer any filter when it serves the - * transactions via its JSON API. => Can be fixed by using the TWG. - */ - -// DEFINITIONS AND HELPERS - -/** - * Timeout the HTTP client waits for the server to respond, - * after the request is made. - */ -val waitTimeout = 30000L - -/** - * Time to wait before HTTP requesting again to the server. - * This helps to avoid tight cycles in case the server responds - * quickly or the client doesn't long-poll. - */ -val newIterationTimeout = 2000L - -/** - * Response format of Nexus GET /transactions. - */ -data class TransactionItem( - val index: String, - val camtData: CamtBankAccountEntry -) -data class NexusTransactions( - val transactions: List -) - -/** - * This exception signals that the buy-in service could NOT - * GET the list of fiat transactions from Nexus due to a client - * error. Because this is fatal (e.g. wrong credentials, URL not found..), - * the service should be stopped. - */ -class BuyinClientError : Exception() - -/** - * This exception signals that POSTing a cash-out operation - * to Nexus failed due to the client. This is a fatal condition - * therefore the monitor should be stopped. - */ -class CashoutClientError : Exception() -/** - * Executes the 'block' function every 'loopNewReqMs' milliseconds. - * Does not exit/fail the process upon exceptions - just logs them. - */ -fun downloadLoop(block: () -> Unit) { - // Needs "runBlocking {}" to call "delay()" and in case 'block' - // contains suspend functions. - runBlocking { - while(true) { - try { block() } - catch (e: BuyinClientError) { - logger.error("The buy-in monitor had a client error while GETting new" + - " transactions from Neuxs. Stopping it") - // Rethrowing and let the caller manage it - throw e - } - // Tolerating any other error type that's not due to the client. - catch (e: Exception) { - logger.error("Sandbox fiat-incoming monitor excepted: ${e.message}") - } - delay(newIterationTimeout) - } - } -} - -// BUY-IN SIDE. - -/** - * Applies the buy-in ratio and fees to the fiat amount - * that came from Nexus. The result is the regional amount - * that will be wired to the exchange Sandbox account. - */ -fun applyBuyinRatioAndFees( - amount: BigDecimal, - ratiosAndFees: RatioAndFees -): BigDecimal { - val maybeBuyinAmount = ((amount * ratiosAndFees.buy_at_ratio.toBigDecimal()) - - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits() - // Bank's fault, as buying in should never lead to negative. - if (maybeBuyinAmount < BigDecimal.ZERO) { - logger.error("Negative buy-in scenario: input fiat amount was '${amount}'" + - ", buy-in ratio was '${ratiosAndFees.buy_at_ratio}'," + - " buy-in fee was '${ratiosAndFees.buy_in_fee}'") - throw internalServerError("Applying buy-in fees yielded negative regional amount") - } - return maybeBuyinAmount -} - -private fun ensureDisabledRedirects(client: HttpClient) { - client.config { - if (followRedirects) throw Exception( - "HTTP client follows redirects, please disable." - ) - } -} -/** - * This function downloads the incoming fiat transactions from Nexus, - * stores them into the database and triggers the related wire transfer - * to the Taler exchange (to be specified in 'accountToCredit'). Once - * started, this function is not supposed to return, except on _client - * side_ errors. On server side errors it pauses and retries. When - * it returns, the caller is expected to handle the error. - */ -fun buyinMonitor( - demobankName: String, // used to get config values. - client: HttpClient, - accountToCredit: String, - accountToDebit: String = "admin" -) { - ensureDisabledRedirects(client) - val demobank = ensureDemobank(demobankName) - /** - * Getting the config values to send authenticated requests - * to Nexus. Sandbox needs one account at Nexus before being - * able to use these values. - */ - val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl) - val usernameAtNexus = getConfigValueOrThrow(demobank.config::usernameAtNexus) - val passwordAtNexus = getConfigValueOrThrow(demobank.config::passwordAtNexus) - /** - * This is the endpoint where Nexus serves all the transactions that - * have ingested from the fiat bank. - */ - val endpoint = "bank-accounts/$usernameAtNexus/transactions" - val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) + "?long_poll_ms=$waitTimeout" - - // downloadLoop does already try-catch (without failing the process). - downloadLoop { - /** - * This bank account will act as the debtor, once a new fiat - * payment is detected. It's the debtor that pays the related - * regional amount to the exchange, in order to start a withdrawal - * operation (in regional coins). - */ - val debitBankAccount = getBankAccountFromLabel(accountToDebit) - /** - * Setting the 'start' URI param in the following command - * lets Sandbox receive only unseen payments from Nexus. - */ - val uriWithStart = "$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}" - runBlocking { - // Maybe get new fiat transactions. - logger.debug("GETting fiat transactions from: $uriWithStart") - val resp = client.get(uriWithStart) { - expectSuccess = false // Avoids excepting on !2xx - basicAuth(usernameAtNexus, passwordAtNexus) - } - // The server failed, pause and try again - if (resp.status.value.toString().startsWith('5')) { - logger.error("Buy-in monitor requested to a failing Nexus. Retry.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - return@runBlocking - } - // The client failed, fail the process. - if (resp.status.value.toString().startsWith('4')) { - logger.error("Buy-in monitor failed at GETting to Nexus. Stopping the buy-in monitor.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - throw BuyinClientError() - } - // Expect 200 OK. What if 3xx? - if (resp.status.value != HttpStatusCode.OK.value) { - logger.error("Unhandled response status ${resp.status.value}, failing Sandbox") - throw BuyinClientError() - } - // Nexus responded 200 OK, analyzing the result. - /** - * Wire to "admin" if the subject is a public key, or do - * nothing otherwise. - */ - val respObj = jacksonObjectMapper().readValue( - resp.bodyAsText(), - NexusTransactions::class.java - ) // errors are logged by the caller (without failing). - respObj.transactions.forEach { - // Ignoring payments with an invalid reserved public key. - if (extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null) - return@forEach - // Extracts the amount and checks it's at most two fractional digits. - val maybeValidAmount = it.camtData.amount.value - if (!validatePlainAmount(maybeValidAmount)) { - logger.error("Nexus gave one amount with invalid fractional digits: $maybeValidAmount." + - " The transaction has index ${it.index}") - // Advancing the last fetched pointer, to avoid GETting - // this invalid payment again. - transaction { - debitBankAccount.refresh() - debitBankAccount.lastFiatFetch = it.index - } - } - val convertedAmount = applyBuyinRatioAndFees( - maybeValidAmount.toBigDecimal(), - ratiosAndFees - ) - transaction { - wireTransfer( - debitAccount = accountToDebit, - creditAccount = accountToCredit, - demobank = demobankName, - subject = it.camtData.getSingletonSubject(), - amount = "${demobank.config.currency}:$convertedAmount" - ) - // Nexus enqueues the transactions such that the index increases. - // If Sandbox crashes here, it'll ask again using the last successful - // index as the start parameter. Being this an exclusive bound, only - // transactions later than it are expected. - debitBankAccount.refresh() - debitBankAccount.lastFiatFetch = it.index - } - } - } - } -} - -/* DB query helper that fetches the latest cash-out operations that were - confirmed in the regional currency. A cash-out operation is 'confirmed' - when the bank account pointed by the parameter 'bankAccountLabel' gets - one incoming payment. - - The List return type (instead of SizedIterable) lets the caller NOT open - a transaction block to access the values -- although some operations _on - the values_ may be forbidden. -*/ -fun getUnsubmittedTransactions(bankAccountLabel: String): List { - return transaction { - val bankAccount = getBankAccountFromLabel(bankAccountLabel) - val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0 - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.id greater lowerExclusiveLimit and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) and (BankAccountTransactionsTable.account eq bankAccount.id) - }.sortedBy { it.id }.map { it } - /* The latest payment must occupy the highest index, - to reliably update the 'lastFiatSubmission' column of - the bank account. */ - } -} - -// CASH-OUT SIDE. - -/** - * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX) - * on the 'watchedBankAccount' and submits the related cash-out payment - * to Nexus. The fiat payment will then take place ENTIRELY on Nexus' - * responsibility. - */ -suspend fun cashoutMonitor( - httpClient: HttpClient, - watchedBankAccount: String = "admin", - demobankName: String = "default", // used to get config values. - dbEventTimeout: Long = 0 // 0 waits forever. -) { - ensureDisabledRedirects(httpClient) - // Register for a REGIO_TX event. - val eventChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - watchedBankAccount - ) - val objectMapper = jacksonObjectMapper() - val demobank = getDemobank(demobankName) - val bankAccount = getBankAccountFromLabel(watchedBankAccount) - val config = demobank?.config ?: throw internalServerError( - "Demobank '$demobankName' has no configuration." - ) - /** - * The monitor needs the cash-out currency to correctly POST - * payment initiations at Nexus. Recall: Nexus bank accounts - * do not mandate any particular currency, as they serve as mere - * bridges to the backing bank. And: a backing bank may have - * multiple currencies, or the backing bank may not explicitly - * specify any currencies to be _the_ currency of the backed - * bank account. - */ - if (config.cashoutCurrency == null) { - logger.error("Config lacks cash-out currency.") - exitProcess(1) - } - val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl) - val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus) - val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus) - val paymentInitEndpoint = nexusBaseUrl.run { - var nexusBaseUrlFromConfig = this - if (!nexusBaseUrlFromConfig.endsWith('/')) - nexusBaseUrlFromConfig += '/' - /** - * WARNING: Nexus gives the possibility to have bank account names - * DIFFERENT from their owner's username. Sandbox however MUST have - * its Nexus bank account named THE SAME as its username. - */ - nexusBaseUrlFromConfig + "bank-accounts/$usernameAtNexus/payment-initiations" - } - while (true) { - val listenHandle = PostgresListenHandle(eventChannel) - // pessimistically LISTEN - listenHandle.postgresListen() - // but optimistically check for data, case some - // arrived _before_ the LISTEN. - var newTxs = getUnsubmittedTransactions(watchedBankAccount) - // Data found, UNLISTEN. - if (newTxs.isNotEmpty()) { - logger.debug("Found cash-out's without waiting any DB event.") - listenHandle.postgresUnlisten() - } - // Data not found, wait. - else { - logger.debug("Need to wait a DB event for new cash-out's") - val isNotificationArrived = listenHandle.waitOnIODispatchers(dbEventTimeout) - if (isNotificationArrived && listenHandle.receivedPayload == "CRDT") - newTxs = getUnsubmittedTransactions(watchedBankAccount) - } - if (newTxs.isEmpty()) { - logger.debug("DB event timeout expired") - continue - } - logger.debug("POSTing new cash-out's") - newTxs.forEach { - logger.debug("POSTing cash-out '${it.subject}' to $paymentInitEndpoint") - val body = object { - /** - * This field is UID of the request _as assigned by the - * client_. That helps to reconcile transactions or lets - * Nexus implement idempotency. It will NOT identify the created - * resource at the server side. The ID of the created resource is - * assigned _by Nexus_ and communicated in the (successful) response. - */ - val uid = it.accountServicerReference - val iban = it.creditorIban - val bic = it.creditorBic - val amount = "${config.cashoutCurrency}:${it.amount}" - val subject = it.subject - val name = it.creditorName - } - val resp = try { - httpClient.post(paymentInitEndpoint) { - expectSuccess = false // Avoids excepting on !2xx - basicAuth(usernameAtNexus, passwordAtNexus) - contentType(ContentType.Application.Json) - setBody(objectMapper.writeValueAsString(body)) - } - } - // Hard-error, response did not even arrive. - catch (e: Exception) { - logger.error("Cash-out monitor could not reach Nexus. Pause and retry") - logger.error(e.message) - /** - * Explicit delaying because the monitor normally - * waits on DB events, and this retry likely won't - * wait on a DB event. - */ - delay(2000) - return@forEach - } - // Server fault. Pause and retry. - if (resp.status.value.toString().startsWith('5')) { - logger.error("Cash-out monitor POSTed to a failing Nexus. Pause and retry") - logger.error("Server responded: ${resp.bodyAsText()}") - /** - * Explicit delaying because the monitor normally - * waits on DB events, and this retry likely won't - * wait on a DB event. - */ - delay(2000L) - return@forEach - } - // Client fault, fail Sandbox. - if (resp.status.value.toString().startsWith('4')) { - logger.error("Cash-out monitor failed at POSTing to Nexus.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - throw CashoutClientError() - } - // Expecting 200 OK. What if 3xx? - if (resp.status.value != HttpStatusCode.OK.value) { - logger.error("Cash-out monitor, unhandled response status: ${resp.status.value}.") - throw CashoutClientError() - } - // Successful case, mark the wire transfer as submitted, - // and advance the pointer to the last submitted payment. - val responseBody = resp.bodyAsText() - transaction { - CashoutSubmissionEntity.new { - localTransaction = it.id - submissionTime = resp.responseTime.timestamp - /** - * The following block associates the submitted payment - * to the UID that Nexus assigned to it. It is currently not - * used in Sandbox, but might help for reconciliation. - */ - if (responseBody.isNotEmpty()) - maybeNexusResposnse = responseBody - } - // Advancing the 'last submitted bookmark', to avoid - // handling the same transaction multiple times. - bankAccount.lastFiatSubmission = it - } - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt deleted file mode 100644 index 523b1bc3..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt +++ /dev/null @@ -1,747 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * 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 - * - */ - -package tech.libeufin.sandbox - -import io.ktor.http.* -import org.jetbrains.exposed.dao.Entity -import org.jetbrains.exposed.dao.EntityClass -import org.jetbrains.exposed.dao.IntEntity -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.IntEntityClass -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.dao.id.IntIdTable -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import kotlin.reflect.* -import kotlin.reflect.full.* - -/** - * All the states to give a subscriber. - */ -enum class SubscriberState { - /** - * No keys at all given to the bank. - */ - NEW, - - /** - * Only INI electronic message was successfully sent. - */ - PARTIALLY_INITIALIZED_INI, - - /**r - * Only HIA electronic message was successfully sent. - */ - PARTIALLY_INITIALIZED_HIA, - - /** - * Both INI and HIA were electronically sent with success. - */ - INITIALIZED, - - /** - * All the keys accounted in INI and HIA have been confirmed - * via physical mail. - */ - READY -} - -/** - * All the states that one key can be assigned. - */ -enum class KeyState { - - /** - * The key was never communicated. - */ - MISSING, - - /** - * The key has been electronically sent. - */ - NEW, - - /** - * The key has been confirmed (either via physical mail - * or electronically -- e.g. with certificates) - */ - RELEASED -} - -/** - * Stores one config object to the database. Each field - * name and value populate respectively the configKey and - * configValue columns. Rows are defined in the following way: - * demobankName | configKey | configValue - */ -fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) { - // Fill the config key-value pairs in the DB. - config::class.declaredMemberProperties.forEach { configField -> - val maybeValue = configField.getter.call(config) - if (override) { - val maybeConfigPair = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.configKey eq configField.name - }.firstOrNull() - if (maybeConfigPair == null) - throw internalServerError("Cannot override config value '${configField.name}' not found.") - maybeConfigPair.configValue = maybeValue?.toString() - return@forEach - } - DemobankConfigPairEntity.new { - this.demobankName = config.demobankName - this.configKey = configField.name - this.configValue = maybeValue?.toString() - } - } -} - -object DemobankConfigPairsTable : LongIdTable() { - val demobankName = text("demobankName") - val configKey = text("configKey") - val configValue = text("configValue").nullable() -} - -class DemobankConfigPairEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(DemobankConfigPairsTable) - var demobankName by DemobankConfigPairsTable.demobankName - var configKey by DemobankConfigPairsTable.configKey - var configValue by DemobankConfigPairsTable.configValue -} - -object DemobankConfigsTable : LongIdTable() { - val name = text("hostname") -} - -// Helpers for handling config values in memory. -typealias DemobankConfigKey = String -typealias DemobankConfigValue = String? -fun Pair.expectValue(): String { - if (this.second == null) throw internalServerError("Config value for '${this.first}' is null in the database.") - return this.second as String -} - -class DemobankConfigEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(DemobankConfigsTable) - var name by DemobankConfigsTable.name - /** - * This object gets defined by parsing all the configuration - * values found in the DB for one demobank. Those values are - * retrieved from _another_ table. - */ - val config: DemobankConfig by lazy { - // Getting all the values for this demobank. - val configPairs: List> = transaction { - val maybeConfigPairs = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName.eq(name) - } - if (maybeConfigPairs.empty()) throw SandboxError( - HttpStatusCode.InternalServerError, - "No config values of $name were found in the database" - ) - // Copying results to a DB-agnostic list, to later operate out of "transaction {}" - maybeConfigPairs.map { Pair(it.configKey, it.configValue) } - } - // Building the args to instantiate a DemobankConfig (non-Exposed) object. - val args = mutableMapOf() - // For each constructor parameter name, find the same-named database entry. - val configClass = DemobankConfig::class - if (configClass.primaryConstructor == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "${configClass.simpleName} primaryConstructor is null." - ) - } - if (configClass.primaryConstructor?.parameters == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "${configClass.simpleName} primaryConstructor" + - " arguments is null. Cannot set any config value." - ) - } - // For each field in the config object, find the respective DB row. - configClass.primaryConstructor?.parameters?.forEach { par: KParameter -> - val configPairFromDb: Pair? - = configPairs.firstOrNull { - configPair: Pair -> - configPair.first == par.name - } - if (configPairFromDb == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "Config key '${par.name}' not found in the database." - ) - } - when(par.type) { - // non-nullable - typeOf() -> { args[par] = configPairFromDb.expectValue().toBoolean() } - typeOf() -> { args[par] = configPairFromDb.expectValue().toInt() } - // nullable - typeOf() -> { args[par] = configPairFromDb.second?.toBoolean() } - typeOf() -> { args[par] = configPairFromDb.second?.toInt() } - else -> args[par] = configPairFromDb.second - } - } - // Proceeding now to instantiate the config class, and make it a field of this type. - configClass.primaryConstructor!!.callBy(args) - } -} - -/** - * Users who are allowed to log into the demo bank. - * Created via the /demobanks/{demobankname}/register endpoint. - */ -object DemobankCustomersTable : LongIdTable() { - val username = text("username") - val passwordHash = text("passwordHash") - val name = text("name").nullable() - val email = text("email").nullable() - val phone = text("phone").nullable() - val cashout_address = text("cashout_address").nullable() -} - -class DemobankCustomerEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(DemobankCustomersTable) - var username by DemobankCustomersTable.username - var passwordHash by DemobankCustomersTable.passwordHash - var name by DemobankCustomersTable.name - var email by DemobankCustomersTable.email - var phone by DemobankCustomersTable.phone - var cashout_address by DemobankCustomersTable.cashout_address -} - -/** - * This table stores RSA public keys of subscribers. - */ -object EbicsSubscriberPublicKeysTable : IntIdTable() { - val rsaPublicKey = blob("rsaPublicKey") - val state = enumeration("state", KeyState::class) -} - -class EbicsSubscriberPublicKeyEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsSubscriberPublicKeysTable) - var rsaPublicKey by EbicsSubscriberPublicKeysTable.rsaPublicKey - var state by EbicsSubscriberPublicKeysTable.state -} - -/** - * Ebics 'host'(s) that are served by one Sandbox instance. - */ -object EbicsHostsTable : IntIdTable() { - val hostID = text("hostID") - val ebicsVersion = text("ebicsVersion") - val signaturePrivateKey = blob("signaturePrivateKey") - val encryptionPrivateKey = blob("encryptionPrivateKey") - val authenticationPrivateKey = blob("authenticationPrivateKey") -} - -class EbicsHostEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsHostsTable) - var hostId by EbicsHostsTable.hostID - var ebicsVersion by EbicsHostsTable.ebicsVersion - var signaturePrivateKey by EbicsHostsTable.signaturePrivateKey - var encryptionPrivateKey by EbicsHostsTable.encryptionPrivateKey - var authenticationPrivateKey by EbicsHostsTable.authenticationPrivateKey -} - -/** - * Ebics Subscribers table. - */ -object EbicsSubscribersTable : IntIdTable() { - val userId = text("userID") - val partnerId = text("partnerID") - val systemId = text("systemID").nullable() - val hostId = text("hostID") - val signatureKey = reference("signatureKey", EbicsSubscriberPublicKeysTable).nullable() - val encryptionKey = reference("encryptionKey", EbicsSubscriberPublicKeysTable).nullable() - val authenticationKey = reference("authorizationKey", EbicsSubscriberPublicKeysTable).nullable() - val nextOrderID = integer("nextOrderID") - val state = enumeration("state", SubscriberState::class) - val bankAccount = reference( - "bankAccount", - BankAccountsTable, - onDelete = ReferenceOption.CASCADE - ).nullable() -} - -class EbicsSubscriberEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsSubscribersTable) - var userId by EbicsSubscribersTable.userId - var partnerId by EbicsSubscribersTable.partnerId - var systemId by EbicsSubscribersTable.systemId - var hostId by EbicsSubscribersTable.hostId - var signatureKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.signatureKey - var encryptionKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.encryptionKey - var authenticationKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.authenticationKey - var nextOrderID by EbicsSubscribersTable.nextOrderID - var state by EbicsSubscribersTable.state - var bankAccount by BankAccountEntity optionalReferencedOn EbicsSubscribersTable.bankAccount -} - -/** - * Details of a download order. - */ -object EbicsDownloadTransactionsTable : IdTable() { - override val id = text("transactionID").entityId() - val orderType = text("orderType") - val host = reference("host", EbicsHostsTable) - val subscriber = reference("subscriber", EbicsSubscribersTable) - val encodedResponse = text("encodedResponse") - val transactionKeyEnc = blob("transactionKeyEnc") - val numSegments = integer("numSegments") - val segmentSize = integer("segmentSize") - val receiptReceived = bool("receiptReceived") -} - -class EbicsDownloadTransactionEntity(id: EntityID) : Entity(id) { - companion object : EntityClass(EbicsDownloadTransactionsTable) - - var orderType by EbicsDownloadTransactionsTable.orderType - var host by EbicsHostEntity referencedOn EbicsDownloadTransactionsTable.host - var subscriber by EbicsSubscriberEntity referencedOn EbicsDownloadTransactionsTable.subscriber - var encodedResponse by EbicsDownloadTransactionsTable.encodedResponse - var numSegments by EbicsDownloadTransactionsTable.numSegments - var transactionKeyEnc by EbicsDownloadTransactionsTable.transactionKeyEnc - var segmentSize by EbicsDownloadTransactionsTable.segmentSize - var receiptReceived by EbicsDownloadTransactionsTable.receiptReceived -} - -/** - * Details of a upload order. - */ -object EbicsUploadTransactionsTable : IdTable() { - override val id = text("transactionID").entityId() - val orderType = text("orderType") - val orderID = text("orderID") - val host = reference("host", EbicsHostsTable) - val subscriber = reference("subscriber", EbicsSubscribersTable) - val numSegments = integer("numSegments") - val lastSeenSegment = integer("lastSeenSegment") - val transactionKeyEnc = blob("transactionKeyEnc") -} - -class EbicsUploadTransactionEntity(id: EntityID) : Entity(id) { - companion object : EntityClass(EbicsUploadTransactionsTable) - var orderType by EbicsUploadTransactionsTable.orderType - var orderID by EbicsUploadTransactionsTable.orderID - var host by EbicsHostEntity referencedOn EbicsUploadTransactionsTable.host - var subscriber by EbicsSubscriberEntity referencedOn EbicsUploadTransactionsTable.subscriber - var numSegments by EbicsUploadTransactionsTable.numSegments - var lastSeenSegment by EbicsUploadTransactionsTable.lastSeenSegment - var transactionKeyEnc by EbicsUploadTransactionsTable.transactionKeyEnc -} - -/** - * FIXME: document this. - */ -object EbicsOrderSignaturesTable : IntIdTable() { - val orderID = text("orderID") - val orderType = text("orderType") - val partnerID = text("partnerID") - val userID = text("userID") - val signatureAlgorithm = text("signatureAlgorithm") - val signatureValue = blob("signatureValue") -} - -class EbicsOrderSignatureEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsOrderSignaturesTable) - var orderID by EbicsOrderSignaturesTable.orderID - var orderType by EbicsOrderSignaturesTable.orderType - var partnerID by EbicsOrderSignaturesTable.partnerID - var userID by EbicsOrderSignaturesTable.userID - var signatureAlgorithm by EbicsOrderSignaturesTable.signatureAlgorithm - var signatureValue by EbicsOrderSignaturesTable.signatureValue -} - -/** - * FIXME: document this. - */ -object EbicsUploadTransactionChunksTable : IdTable() { - override val id = text("transactionID").entityId() - val chunkIndex = integer("chunkIndex") - val chunkContent = blob("chunkContent") -} - -// FIXME: Is upload chunking not implemented somewhere?! -class EbicsUploadTransactionChunkEntity(id: EntityID) : Entity(id) { - companion object : EntityClass(EbicsUploadTransactionChunksTable) - var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex - var chunkContent by EbicsUploadTransactionChunksTable.chunkContent -} - - -/** - * Holds those transactions that aren't yet reported in a Camt.053 document. - * After reporting those, the table gets emptied. Rows are merely references - * to the main ledger. - */ -object BankAccountFreshTransactionsTable : LongIdTable() { - val transactionRef = reference( - "transaction", - BankAccountTransactionsTable, - onDelete = ReferenceOption.CASCADE - ) -} -class BankAccountFreshTransactionEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(BankAccountFreshTransactionsTable) - var transactionRef by BankAccountTransactionEntity referencedOn BankAccountFreshTransactionsTable.transactionRef -} - -/** - * Table that keeps all the payments initiated by PAIN.001. - */ -object BankAccountTransactionsTable : LongIdTable() { - val creditorIban = text("creditorIban") - val creditorBic = text("creditorBic").nullable() - val creditorName = text("creditorName") - val debtorIban = text("debtorIban") - val debtorBic = text("debtorBic").nullable() - val debtorName = text("debtorName") - val subject = text("subject") - // Amount is a BigDecimal in String form. - val amount = text("amount") - val currency = text("currency") - // Milliseconds since the Epoch. - val date = long("date") - - /** - * UID assigned to the payment by Sandbox. Despite the camt-looking - * name, this UID is always given, even when no EBICS or camt are being - * served. - */ - val accountServicerReference = text("accountServicerReference") - /** - * The following two values are pain.001 specific. Sandbox stores - * them when it serves EBICS connections. - */ - val pmtInfId = text("pmtInfId").nullable() - val endToEndId = text("EndToEndId").nullable() - val direction = text("direction") - /** - * Bank account of the party whose 'direction' refers. This version allows - * only both parties to be registered at the running Sandbox. - */ - val account = reference( - "account", BankAccountsTable, - onDelete = ReferenceOption.CASCADE - ) - // Redundantly storing the demobank for query convenience. - val demobank = reference("demobank", DemobankConfigsTable) -} - -class BankAccountTransactionEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(BankAccountTransactionsTable) { - override fun new(init: BankAccountTransactionEntity.() -> Unit): BankAccountTransactionEntity { - /** - * Fresh transactions are those that wait to be included in a - * "history" report, likely a Camt.5x message. The "fresh transactions" - * table keeps a list of such transactions. - */ - val freshTx = super.new(init) - BankAccountFreshTransactionsTable.insert { - it[transactionRef] = freshTx.id - } - /** - * The bank account involved in this transaction points to - * it as the "last known" transaction, to make it easier to - * build histories that depend on such record. - */ - freshTx.account.lastTransaction = freshTx - return freshTx - } - } - var creditorIban by BankAccountTransactionsTable.creditorIban - var creditorBic by BankAccountTransactionsTable.creditorBic - var creditorName by BankAccountTransactionsTable.creditorName - var debtorIban by BankAccountTransactionsTable.debtorIban - var debtorBic by BankAccountTransactionsTable.debtorBic - var debtorName by BankAccountTransactionsTable.debtorName - var subject by BankAccountTransactionsTable.subject - var amount by BankAccountTransactionsTable.amount - var currency by BankAccountTransactionsTable.currency - var date by BankAccountTransactionsTable.date - var accountServicerReference by BankAccountTransactionsTable.accountServicerReference - var pmtInfId by BankAccountTransactionsTable.pmtInfId - var endToEndId by BankAccountTransactionsTable.endToEndId - var direction by BankAccountTransactionsTable.direction - var account by BankAccountEntity referencedOn BankAccountTransactionsTable.account - var demobank by DemobankConfigEntity referencedOn BankAccountTransactionsTable.demobank -} - -/** - * Table that keeps information about which bank accounts (iban+bic+name) - * are active in the system. In the current version, 'label' and 'owner' - * are always equal; future versions may change this, when one customer can - * own multiple bank accounts. - */ -object BankAccountsTable : IntIdTable() { - val balance = text("balance").default("0") - val iban = text("iban") - val bic = text("bic").default("SANDBOXX") - val label = text("label").uniqueIndex("accountLabelIndex") - /** - * This field is the username of the customer that owns the - * bank account. Admin is the only exception: that can specify - * this field as "admin" although no customer backs it. - */ - val owner = text("owner") - val isPublic = bool("isPublic").default(false) - val demoBank = reference("demoBank", DemobankConfigsTable) - - /** - * Point to the last transaction related to this account, regardless - * of it being credit or debit. This reference helps to construct - * history results that start from / depend on the last transaction. - */ - val lastTransaction = reference("lastTransaction", BankAccountTransactionsTable).nullable() - - /** - * Points to the transaction that was last submitted by the conversion - * service to Nexus, in order to initiate a fiat payment related to a - * cash-out operation. - */ - val lastFiatSubmission = reference("lastFiatSubmission", BankAccountTransactionsTable).nullable() - - /** - * Tracks the last fiat payment that was read from Nexus. This tracker - * gets updated ONLY IF the exchange gets successfully paid with the related - * amount in the regional currency. - */ - val lastFiatFetch = text("lastFiatFetch").default("0") -} - -class BankAccountEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(BankAccountsTable) - - var balance by BankAccountsTable.balance - var iban by BankAccountsTable.iban - var bic by BankAccountsTable.bic - var label by BankAccountsTable.label - var owner by BankAccountsTable.owner - var isPublic by BankAccountsTable.isPublic - var demoBank by DemobankConfigEntity referencedOn BankAccountsTable.demoBank - var lastTransaction by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastTransaction - var lastFiatSubmission by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastFiatSubmission - var lastFiatFetch by BankAccountsTable.lastFiatFetch -} - -object BankAccountStatementsTable : IntIdTable() { - val statementId = text("statementId") - val creationTime = long("creationTime") - val xmlMessage = text("xmlMessage") - val bankAccount = reference("bankAccount", BankAccountsTable) - // Signed BigDecimal representing a Camt.053 CLBD field. - val balanceClbd = text("balanceClbd").nullable() -} - -class BankAccountStatementEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(BankAccountStatementsTable) - var statementId by BankAccountStatementsTable.statementId - var creationTime by BankAccountStatementsTable.creationTime - var xmlMessage by BankAccountStatementsTable.xmlMessage - var bankAccount by BankAccountEntity referencedOn BankAccountStatementsTable.bankAccount - var balanceClbd by BankAccountStatementsTable.balanceClbd -} - -enum class CashoutOperationStatus { CONFIRMED, PENDING } -object CashoutOperationsTable : LongIdTable() { - val uuid = uuid("uuid").autoGenerate() - /** - * This amount is the one the user entered in the cash-out - * dialog. That will show up as the outgoing transfer in their - * local currency bank account. - */ - val amountDebit = text("amountDebit") - val amountCredit = text("amountCredit") - val buyAtRatio = text("buyAtRatio") - val buyInFee = text("buyInFee") - val sellAtRatio = text("sellAtRatio") - val sellOutFee = text("sellOutFee") - val subject = text("subject") - val creationTime = long("creationTime") // in milliseconds. - val confirmationTime = long("confirmationTime").nullable() // in milliseconds. - val tanChannel = enumeration("tanChannel", SupportedTanChannels::class) - val account = text("account") - val cashoutAddress = text("cashoutAddress") - val tan = text("tan") - val status = enumeration("status", CashoutOperationStatus::class).default(CashoutOperationStatus.PENDING) -} - -class CashoutOperationEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(CashoutOperationsTable) - var uuid by CashoutOperationsTable.uuid - var amountDebit by CashoutOperationsTable.amountDebit - var amountCredit by CashoutOperationsTable.amountCredit - var buyAtRatio by CashoutOperationsTable.buyAtRatio - var buyInFee by CashoutOperationsTable.buyInFee - var sellAtRatio by CashoutOperationsTable.sellAtRatio - var sellOutFee by CashoutOperationsTable.sellOutFee - var subject by CashoutOperationsTable.subject - var creationTime by CashoutOperationsTable.creationTime - var confirmationTime by CashoutOperationsTable.confirmationTime - var tanChannel by CashoutOperationsTable.tanChannel - var account by CashoutOperationsTable.account - var cashoutAddress by CashoutOperationsTable.cashoutAddress - var tan by CashoutOperationsTable.tan - var status by CashoutOperationsTable.status -} -object TalerWithdrawalsTable : LongIdTable() { - val wopid = uuid("wopid").autoGenerate() - val amount = text("amount") // $currency:x.y - /** - * Turns to true after the wallet gave the reserve public key - * and the exchange details to the bank. - */ - val selectionDone = bool("selectionDone").default(false) - val aborted = bool("aborted").default(false) - /** - * Turns to true after the wire transfer to the exchange bank account - * gets completed _on the bank's side_. This does never guarantees that - * the payment arrived at the exchange's bank yet. - */ - val confirmationDone = bool("confirmationDone").default(false) - val reservePub = text("reservePub").nullable() - val selectedExchangePayto = text("selectedExchangePayto").nullable() - val walletBankAccount = reference("walletBankAccount", BankAccountsTable) -} -class TalerWithdrawalEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(TalerWithdrawalsTable) - var wopid by TalerWithdrawalsTable.wopid - var selectionDone by TalerWithdrawalsTable.selectionDone - var confirmationDone by TalerWithdrawalsTable.confirmationDone - var reservePub by TalerWithdrawalsTable.reservePub - var selectedExchangePayto by TalerWithdrawalsTable.selectedExchangePayto - var amount by TalerWithdrawalsTable.amount - var walletBankAccount by BankAccountEntity referencedOn TalerWithdrawalsTable.walletBankAccount - var aborted by TalerWithdrawalsTable.aborted -} - -object BankAccountReportsTable : IntIdTable() { - val reportId = text("reportId") - val creationTime = long("creationTime") - val xmlMessage = text("xmlMessage") - val bankAccount = reference("bankAccount", BankAccountsTable) -} - -/** - * This table tracks the cash-out requests that Sandbox sends to Nexus. - * Only successful requests make it to this table. Failed request would - * either _stop_ the conversion service (for client-side errors) or get retried - * at a later time (for server-side errors.) - */ -object CashoutSubmissionsTable: LongIdTable() { - val localTransaction = reference("localTransaction", BankAccountTransactionsTable).uniqueIndex() - val maybeNexusResponse = text("maybeNexusResponse").nullable() - val submissionTime = long("submissionTime").nullable() // failed don't have it. -} - -class CashoutSubmissionEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(CashoutSubmissionsTable) - var localTransaction by CashoutSubmissionsTable.localTransaction - var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse - var submissionTime by CashoutSubmissionsTable.submissionTime -} - -fun dbDropTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - val ret = execCommand( - listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "sandbox", - "-r" // the drop option - ), - /** - * Tolerating a failure here helps to manage the case - * where an empty database is attempted to be dropped. - */ - throwIfFails = false - ) - if (ret != 0) - logger.warn("Dropping the sandbox tables failed. Was the DB filled before?") - return - } - transaction { - SchemaUtils.drop( - CashoutSubmissionsTable, - EbicsSubscribersTable, - EbicsSubscriberPublicKeysTable, - EbicsHostsTable, - EbicsDownloadTransactionsTable, - EbicsUploadTransactionsTable, - EbicsUploadTransactionChunksTable, - EbicsOrderSignaturesTable, - BankAccountTransactionsTable, - BankAccountFreshTransactionsTable, - BankAccountsTable, - BankAccountReportsTable, - BankAccountStatementsTable, - DemobankConfigsTable, - DemobankConfigPairsTable, - TalerWithdrawalsTable, - DemobankCustomersTable, - CashoutOperationsTable - ) - } - -} - -fun dbCreateTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - execCommand(listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "sandbox" - )) - return - } - // Still using the legacy way for other DBMSs, like SQLite. - transaction { - SchemaUtils.create( - CashoutSubmissionsTable, - DemobankConfigsTable, - DemobankConfigPairsTable, - EbicsSubscribersTable, - EbicsSubscriberPublicKeysTable, - EbicsHostsTable, - EbicsDownloadTransactionsTable, - EbicsUploadTransactionsTable, - EbicsUploadTransactionChunksTable, - EbicsOrderSignaturesTable, - BankAccountTransactionsTable, - BankAccountFreshTransactionsTable, - BankAccountsTable, - BankAccountReportsTable, - BankAccountStatementsTable, - TalerWithdrawalsTable, - DemobankCustomersTable, - CashoutOperationsTable - ) - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt deleted file mode 100644 index 79f7a404..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt +++ /dev/null @@ -1,665 +0,0 @@ -package tech.libeufin.sandbox - -import org.postgresql.jdbc.PgConnection -import tech.libeufin.util.internalServerError - -import java.sql.DriverManager -import java.sql.PreparedStatement -import java.sql.SQLException -import java.util.* - -private const val DB_CTR_LIMIT = 1000000 - -data class Customer( - val login: String, - val passwordHash: String, - val name: String, - val email: String, - val phone: String, - val cashoutPayto: String, - val cashoutCurrency: String -) - -data class TalerAmount( - val value: Long, - val frac: Int -) - -data class BankAccount( - val iban: String, - val bic: String, - val bankAccountLabel: String, - val owningCustomerId: Long, - val isPublic: Boolean = false, - val lastNexusFetchRowId: Long, - val balance: TalerAmount? = null, - val hasDebt: Boolean -) - -enum class TransactionDirection { - credit, debit -} - -enum class TanChannel { - sms, email, file -} - -data class BankInternalTransaction( - val creditorAccountId: Long, - val debtorAccountId: Long, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, - val accountServicerReference: String, - val endToEndId: String, - val paymentInformationId: String -) - -data class BankAccountTransaction( - val creditorIban: String, - val creditorBic: String, - val creditorName: String, - val debtorIban: String, - val debtorBic: String, - val debtorName: String, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, // microseconds - val accountServicerReference: String, - val paymentInformationId: String, - val endToEndId: String, - val direction: TransactionDirection, - val bankAccountId: Long, -) - -data class TalerWithdrawalOperation( - val withdrawalUuid: UUID, - val amount: TalerAmount, - val selectionDone: Boolean = false, - val aborted: Boolean = false, - val confirmationDone: Boolean = false, - val reservePub: ByteArray?, - val selectedExchangePayto: String?, - val walletBankAccount: Long -) - -data class Cashout( - val cashoutUuid: UUID, - val localTransaction: Long? = null, - val amountDebit: TalerAmount, - val amountCredit: TalerAmount, - val buyAtRatio: Int, - val buyInFee: TalerAmount, - val sellAtRatio: Int, - val sellOutFee: TalerAmount, - val subject: String, - val creationTime: Long, - val tanConfirmationTime: Long? = null, - val tanChannel: TanChannel, - val tanCode: String, - val bankAccount: Long, - val cashoutAddress: String, - val cashoutCurrency: String -) - -class Database(private val dbConfig: String) { - private var dbConn: PgConnection? = null - private var dbCtr: Int = 0 - private val preparedStatements: MutableMap = mutableMapOf() - - init { - Class.forName("org.postgresql.Driver") - } - private fun reconnect() { - dbCtr++ - val myDbConn = dbConn - if ((dbCtr < DB_CTR_LIMIT && myDbConn != null) && !(myDbConn.isClosed)) - return - dbConn?.close() - preparedStatements.clear() - dbConn = DriverManager.getConnection(dbConfig).unwrap(PgConnection::class.java) - dbCtr = 0 - dbConn?.execSQLUpdate("SET search_path TO libeufin_bank;") - } - - private fun prepare(sql: String): PreparedStatement { - var ps = preparedStatements[sql] - if (ps != null) return ps - val myDbConn = dbConn - if (myDbConn == null) throw internalServerError("DB connection down") - ps = myDbConn.prepareStatement(sql) - preparedStatements[sql] = ps - return ps - } - - /** - * Helper that returns false if the row to be inserted - * hits a unique key constraint violation, true when it - * succeeds. Any other error (re)throws exception. - */ - private fun myExecute(stmt: PreparedStatement): Boolean { - try { - stmt.execute() - } catch (e: SQLException) { - logger.error(e.message) - // NOTE: it seems that _every_ error gets the 0 code. - if (e.errorCode == 0) return false - // rethrowing, not to hide other types of errors. - throw e - } - return true - } - - // CONFIG - fun configGet(configKey: String): String? { - reconnect() - val stmt = prepare("SELECT config_value FROM configuration WHERE config_key=?;") - stmt.setString(1, configKey) - val rs = stmt.executeQuery() - rs.use { - if(!it.next()) return null - return it.getString("config_value") - } - } - fun configSet(configKey: String, configValue: String) { - reconnect() - val stmt = prepare("CALL bank_set_config(TEXT(?), TEXT(?))") - stmt.setString(1, configKey) - stmt.setString(2, configValue) - stmt.execute() - } - - // CUSTOMERS - fun customerCreate(customer: Customer): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO customers ( - login - ,password_hash - ,name - ,email - ,phone - ,cashout_payto - ,cashout_currency - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - """ - ) - stmt.setString(1, customer.login) - stmt.setString(2, customer.passwordHash) - stmt.setString(3, customer.name) - stmt.setString(4, customer.email) - stmt.setString(5, customer.phone) - stmt.setString(6, customer.cashoutPayto) - stmt.setString(7, customer.cashoutCurrency) - - return myExecute(stmt) - } - fun customerGetFromLogin(login: String): Customer? { - reconnect() - val stmt = prepare(""" - SELECT - password_hash, - name, - email, - phone, - cashout_payto, - cashout_currency - FROM customers - WHERE login=? - """) - stmt.setString(1, login) - val rs = stmt.executeQuery() - rs.use { - if (!rs.next()) return null - return Customer( - login = login, - passwordHash = it.getString("password_hash"), - name = it.getString("name"), - phone = it.getString("phone"), - email = it.getString("email"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutPayto = it.getString("cashout_payto") - ) - } - } - // Possibly more "customerGetFrom*()" to come. - - // BANK ACCOUNTS - // Returns false on conflicts. - fun bankAccountCreate(bankAccount: BankAccount): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO bank_accounts - (iban - ,bic - ,bank_account_label - ,owning_customer_id - ,is_public - ,last_nexus_fetch_row_id - ) - VALUES (?, ?, ?, ?, ?, ?) - """) - stmt.setString(1, bankAccount.iban) - stmt.setString(2, bankAccount.bic) - stmt.setString(3, bankAccount.bankAccountLabel) - stmt.setLong(4, bankAccount.owningCustomerId) - stmt.setBoolean(5, bankAccount.isPublic) - stmt.setLong(6, bankAccount.lastNexusFetchRowId) - // using the default zero value for the balance. - return myExecute(stmt) - } - - fun bankAccountSetMaxDebt( - bankAccountLabel: String, - maxDebt: TalerAmount - ): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE bank_accounts - SET max_debt=(?,?)::taler_amount - WHERE bank_account_label=? - """) - stmt.setLong(1, maxDebt.value) - stmt.setInt(2, maxDebt.frac) - stmt.setString(3, bankAccountLabel) - return myExecute(stmt) - } - - fun bankAccountGetFromLabel(bankAccountLabel: String): BankAccount? { - reconnect() - val stmt = prepare(""" - SELECT - iban - ,bic - ,owning_customer_id - ,is_public - ,last_nexus_fetch_row_id - ,(balance).val AS balance_value - ,(balance).frac AS balance_frac - ,has_debt - FROM bank_accounts - WHERE bank_account_label=? - """) - stmt.setString(1, bankAccountLabel) - - val rs = stmt.executeQuery() - rs.use { - if (!it.next()) return null - return BankAccount( - iban = it.getString("iban"), - bic = it.getString("bic"), - balance = TalerAmount( - it.getLong("balance_value"), - it.getInt("balance_frac") - ), - bankAccountLabel = bankAccountLabel, - lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), - owningCustomerId = it.getLong("owning_customer_id"), - hasDebt = it.getBoolean("has_debt") - ) - } - } - // More bankAccountGetFrom*() to come, on a needed basis. - - // BANK ACCOUNT TRANSACTIONS - enum class BankTransactionResult { - NO_CREDITOR, - NO_DEBTOR, - SUCCESS, - CONFLICT - } - fun bankTransactionCreate( - tx: BankInternalTransaction - ): BankTransactionResult { - reconnect() - val stmt = prepare(""" - SELECT out_nx_creditor, out_nx_debtor, out_balance_insufficient - FROM bank_wire_transfer(?,?,TEXT(?),(?,?)::taler_amount,?,TEXT(?),TEXT(?),TEXT(?)) - """ - ) - stmt.setLong(1, tx.creditorAccountId) - stmt.setLong(2, tx.debtorAccountId) - stmt.setString(3, tx.subject) - stmt.setLong(4, tx.amount.value) - stmt.setInt(5, tx.amount.frac) - stmt.setLong(6, tx.transactionDate) - stmt.setString(7, tx.accountServicerReference) - stmt.setString(8, tx.paymentInformationId) - stmt.setString(9, tx.endToEndId) - val rs = stmt.executeQuery() - rs.use { - if (!rs.next()) throw internalServerError("Bank transaction didn't properly return") - if (rs.getBoolean("out_nx_debtor")) { - logger.error("No debtor account found") - return BankTransactionResult.NO_DEBTOR - } - if (rs.getBoolean("out_nx_creditor")) { - logger.error("No creditor account found") - return BankTransactionResult.NO_CREDITOR - } - if (rs.getBoolean("out_balance_insufficient")) { - logger.error("Balance insufficient") - return BankTransactionResult.CONFLICT - } - return BankTransactionResult.SUCCESS - } - } - - fun bankTransactionGetForHistoryPage( - upperBound: Long, - bankAccountId: Long, - fromMs: Long, - toMs: Long - ): List { - reconnect() - val stmt = prepare(""" - SELECT - creditor_iban - ,creditor_bic - ,creditor_name - ,debtor_iban - ,debtor_bic - ,debtor_name - ,subject - ,(amount).val AS amount_val - ,(amount).frac AS amount_frac - ,transaction_date - ,account_servicer_reference - ,payment_information_id - ,end_to_end_id - ,direction - ,bank_account_id - FROM bank_account_transactions - WHERE bank_transaction_id < ? - AND bank_account_id=? - AND transaction_date BETWEEN ? AND ? - """) - stmt.setLong(1, upperBound) - stmt.setLong(2, bankAccountId) - stmt.setLong(3, fromMs) - stmt.setLong(4, toMs) - val rs = stmt.executeQuery() - rs.use { - val ret = mutableListOf() - if (!it.next()) return ret - do { - ret.add( - BankAccountTransaction( - creditorIban = it.getString("creditor_iban"), - creditorBic = it.getString("creditor_bic"), - creditorName = it.getString("creditor_name"), - debtorIban = it.getString("debtor_iban"), - debtorBic = it.getString("debtor_bic"), - debtorName = it.getString("debtor_name"), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac") - ), - accountServicerReference = it.getString("account_servicer_reference"), - endToEndId = it.getString("end_to_end_id"), - direction = it.getString("direction").run { - when(this) { - "credit" -> TransactionDirection.credit - "debit" -> TransactionDirection.debit - else -> throw internalServerError("Wrong direction in transaction: $this") - } - }, - bankAccountId = it.getLong("bank_account_id"), - paymentInformationId = it.getString("payment_information_id"), - subject = it.getString("subject"), - transactionDate = it.getLong("transaction_date") - )) - } while (it.next()) - return ret - } - } - - // WITHDRAWALS - fun talerWithdrawalCreate( - opUUID: UUID, - walletBankAccount: Long, - amount: TalerAmount - ): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO - taler_withdrawal_operations - (withdrawal_uuid, wallet_bank_account, amount) - VALUES (?,?,(?,?)::taler_amount) - """) // Take all defaults from the SQL. - stmt.setObject(1, opUUID) - stmt.setLong(2, walletBankAccount) - stmt.setLong(3, amount.value) - stmt.setInt(4, amount.frac) - - return myExecute(stmt) - } - fun talerWithdrawalGet(opUUID: UUID): TalerWithdrawalOperation? { - reconnect() - val stmt = prepare(""" - SELECT - (amount).val as amount_val - ,(amount).frac as amount_frac - ,withdrawal_uuid - ,selection_done - ,aborted - ,confirmation_done - ,reserve_pub - ,selected_exchange_payto - ,wallet_bank_account - FROM taler_withdrawal_operations - WHERE withdrawal_uuid=? - """) - stmt.setObject(1, opUUID) - stmt.executeQuery().use { - if (!it.next()) return null - return TalerWithdrawalOperation( - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac") - ), - selectionDone = it.getBoolean("selection_done"), - selectedExchangePayto = it.getString("selected_exchange_payto"), - walletBankAccount = it.getLong("wallet_bank_account"), - confirmationDone = it.getBoolean("confirmation_done"), - aborted = it.getBoolean("aborted"), - reservePub = it.getBytes("reserve_pub"), - withdrawalUuid = it.getObject("withdrawal_uuid") as UUID - ) - } - } - - // Values coming from the wallet. - fun talerWithdrawalSetDetails( - opUUID: UUID, - exchangePayto: String, - reservePub: ByteArray - ): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE taler_withdrawal_operations - SET selected_exchange_payto = ?, reserve_pub = ?, selection_done = true - WHERE withdrawal_uuid=? - """ - ) - stmt.setString(1, exchangePayto) - stmt.setBytes(2, reservePub) - stmt.setObject(3, opUUID) - return myExecute(stmt) - } - fun talerWithdrawalConfirm(opUUID: UUID): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE taler_withdrawal_operations - SET confirmation_done = true - WHERE withdrawal_uuid=? - """ - ) - stmt.setObject(1, opUUID) - return myExecute(stmt) - } - - fun cashoutCreate(op: Cashout): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO cashout_operations ( - cashout_uuid - ,amount_debit - ,amount_credit - ,buy_at_ratio - ,buy_in_fee - ,sell_at_ratio - ,sell_out_fee - ,subject - ,creation_time - ,tan_channel - ,tan_code - ,bank_account - ,cashout_address - ,cashout_currency - ) - VALUES ( - ? - ,(?,?)::taler_amount - ,(?,?)::taler_amount - ,? - ,(?,?)::taler_amount - ,? - ,(?,?)::taler_amount - ,? - ,? - ,?::tan_enum - ,? - ,? - ,? - ,? - ); - """) - stmt.setObject(1, op.cashoutUuid) - stmt.setLong(2, op.amountDebit.value) - stmt.setInt(3, op.amountDebit.frac) - stmt.setLong(4, op.amountCredit.value) - stmt.setInt(5, op.amountCredit.frac) - stmt.setInt(6, op.buyAtRatio) - stmt.setLong(7, op.buyInFee.value) - stmt.setInt(8, op.buyInFee.frac) - stmt.setInt(9, op.sellAtRatio) - stmt.setLong(10, op.sellOutFee.value) - stmt.setInt(11, op.sellOutFee.frac) - stmt.setString(12, op.subject) - stmt.setLong(13, op.creationTime) - stmt.setString(14, op.tanChannel.name) - stmt.setString(15, op.tanCode) - stmt.setLong(16, op.bankAccount) - stmt.setString(17, op.cashoutAddress) - stmt.setString(18, op.cashoutCurrency) - return myExecute(stmt) - } - - fun cashoutConfirm( - opUuid: UUID, - tanConfirmationTimestamp: Long, - bankTransaction: Long // regional payment backing the operation - ): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE cashout_operations - SET tan_confirmation_time = ?, local_transaction = ? - WHERE cashout_uuid=?; - """) - stmt.setLong(1, tanConfirmationTimestamp) - stmt.setLong(2, bankTransaction) - stmt.setObject(3, opUuid) - return myExecute(stmt) - } - // used by /abort - enum class CashoutDeleteResult { - SUCCESS, - CONFLICT_ALREADY_CONFIRMED - } - fun cashoutDelete(opUuid: UUID): CashoutDeleteResult { - val stmt = prepare(""" - SELECT out_already_confirmed - FROM cashout_delete(?) - """) - stmt.setObject(1, opUuid) - stmt.executeQuery().use { - if (!it.next()) { - throw internalServerError("Cashout deletion gave no result") - } - if (it.getBoolean("out_already_confirmed")) return CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED - return CashoutDeleteResult.SUCCESS - } - } - fun cashoutGetFromUuid(opUuid: UUID): Cashout? { - val stmt = prepare(""" - SELECT - (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 - ,buy_at_ratio - ,(buy_in_fee).val as buy_in_fee_val - ,(buy_in_fee).frac as buy_in_fee_frac - ,sell_at_ratio - ,(sell_out_fee).val as sell_out_fee_val - ,(sell_out_fee).frac as sell_out_fee_frac - ,subject - ,creation_time - ,tan_channel - ,tan_code - ,bank_account - ,cashout_address - ,cashout_currency - ,tan_confirmation_time - ,local_transaction - FROM cashout_operations - WHERE cashout_uuid=?; - """) - stmt.setObject(1, opUuid) - stmt.executeQuery().use { - if (!it.next()) return null - return Cashout( - amountDebit = TalerAmount( - value = it.getLong("amount_debit_val"), - frac = it.getInt("amount_debit_frac") - ), - amountCredit = TalerAmount( - value = it.getLong("amount_credit_val"), - frac = it.getInt("amount_credit_frac") - ), - bankAccount = it.getLong("bank_account"), - buyAtRatio = it.getInt("buy_at_ratio"), - buyInFee = TalerAmount( - value = it.getLong("buy_in_fee_val"), - frac = it.getInt("buy_in_fee_frac") - ), - cashoutAddress = it.getString("cashout_address"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutUuid = opUuid, - creationTime = it.getLong("creation_time"), - sellAtRatio = it.getInt("sell_at_ratio"), - sellOutFee = TalerAmount( - value = it.getLong("sell_out_fee_val"), - frac = it.getInt("sell_out_fee_frac") - ), - subject = it.getString("subject"), - tanChannel = it.getString("tan_channel").run { - when(this) { - "sms" -> TanChannel.sms - "email" -> TanChannel.email - "file" -> TanChannel.file - else -> throw internalServerError("TAN channel $this unsupported") - } - }, - tanCode = it.getString("tan_code"), - localTransaction = it.getLong("local_transaction"), - tanConfirmationTime = it.getLong("tan_confirmation_time").run { - if (this == 0L) return@run null - return@run this - } - ) - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt deleted file mode 100644 index 57a61f50..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt +++ /dev/null @@ -1,1436 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * 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 - * - */ - - -package tech.libeufin.sandbox - -import io.ktor.server.application.* -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.* -import io.ktor.server.response.respond -import io.ktor.server.response.respondText -import io.ktor.util.AttributeKey -import io.ktor.util.date.* -import org.apache.xml.security.binding.xmldsig.RSAKeyValueType -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import org.w3c.dom.Document -import tech.libeufin.util.* -import tech.libeufin.util.XMLUtil.Companion.signEbicsResponse -import tech.libeufin.util.ebics_h004.* -import tech.libeufin.util.ebics_hev.HEVResponse -import tech.libeufin.util.ebics_hev.SystemReturnCodeType -import tech.libeufin.util.ebics_s001.SignatureTypes -import tech.libeufin.util.ebics_s001.UserSignatureData -import java.math.BigDecimal -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import java.sql.Connection -import java.util.* -import java.util.zip.DeflaterInputStream -import java.util.zip.InflaterInputStream - -val EbicsHostIdAttribute = AttributeKey("RequestedEbicsHostID") - -data class PainParseResult( - val creditorIban: String, - val creditorName: String, - val creditorBic: String?, - val debtorIban: String, - val debtorName: String, - val debtorBic: String?, - val subject: String, - val amount: String, - val currency: String, - val pmtInfId: String, - val endToEndId: String, - val msgId: String -) - -open class EbicsRequestError( - val errorText: String, - val errorCode: String -) : Exception("$errorText (EBICS error code: $errorCode)") - -class EbicsNoDownloadDataAvailable(reason: String? = null) : EbicsRequestError( - "[EBICS_NO_DOWNLOAD_DATA_AVAILABLE]" + if (reason != null) " $reason" else "", - "090005" -) - -class EbicsInvalidRequestError : EbicsRequestError( - "[EBICS_INVALID_REQUEST] Invalid request", - "060102" -) -class EbicsAccountAuthorisationFailed(reason: String) : EbicsRequestError( - "[EBICS_ACCOUNT_AUTHORISATION_FAILED] $reason", - "091302" -) - -/** - * This error is thrown whenever the Subscriber's state is not suitable - * for the requested action. For example, the subscriber sends a EbicsRequest - * message without having first uploaded their keys (#5973). - */ -class EbicsSubscriberStateError : EbicsRequestError( - "[EBICS_INVALID_USER_OR_USER_STATE] Subscriber unknown or subscriber state inadmissible", - "091002" -) -// hint should mention at least the userID -class EbicsUserUnknown(hint: String) : EbicsRequestError( - "[EBICS_USER_UNKNOWN] $hint", - "091003" -) - -class EbicsOrderParamsIgnored(hint: String) : EbicsRequestError( - "[EBICS_ORDER_PARAMS_IGNORED] $hint", - "031001" -) - - -open class EbicsKeyManagementError(private val errorText: String, private val errorCode: String) : - Exception("EBICS key management error: $errorText ($errorCode)") - -private class EbicsInvalidXmlError : EbicsKeyManagementError( - "[EBICS_INVALID_XML]", - "091010" -) - -private class EbicsUnsupportedOrderType : EbicsRequestError( - "[EBICS_UNSUPPORTED_ORDER_TYPE] Order type not supported", - "091005" -) - -/** - * Used here also for "Internal server error". For example, when the - * sandbox itself generates a invalid XML response. - */ -class EbicsProcessingError(detail: String?) : EbicsRequestError( - // a missing detail is already the bank's fault. - "[EBICS_PROCESSING_ERROR] " + (detail ?: "bank internal error"), - "091116" -) - -class EbicsAmountCheckError(detail: String): EbicsRequestError( - "[EBICS_AMOUNT_CHECK_FAILED] $detail", - "091303" -) - -suspend fun respondEbicsTransfer( - call: ApplicationCall, - errorText: String, - errorCode: String -) { - /** - * Because this handler runs for any error, it could - * handle the case where the Ebics host ID is unknown due - * to an invalid request. Recall: Sandbox is multi-host, and - * which Ebics host was requested belongs to the request document. - * - * Therefore, because any Ebics response - * should speak for one Ebics host, we can't respond any Ebics - * type when the Ebics host ID remains unknown due to invalid - * request. Instead, we'll respond plain text: - */ - if (!call.attributes.contains(EbicsHostIdAttribute)) { - call.respondText("Invalid document.", status = HttpStatusCode.BadRequest) - return - } - val resp = EbicsResponse.createForUploadWithError( - errorText, - errorCode, - // For now, phase gets hard-coded as TRANSFER, - // because errors during initialization should have - // already been caught by the chunking logic. - EbicsTypes.TransactionPhaseType.TRANSFER - ) - val hostAuthPriv = transaction { - val host = EbicsHostEntity.find { - EbicsHostsTable.hostID.upperCase() eq call.attributes[EbicsHostIdAttribute] - .uppercase() - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Requested Ebics host ID (${call.attributes[EbicsHostIdAttribute]}) not found." - ) - CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes) - } - call.respondText( - signEbicsResponse(resp, hostAuthPriv), - ContentType.Application.Xml, - HttpStatusCode.OK - ) -} - -private suspend fun ApplicationCall.respondEbicsKeyManagement( - errorText: String, - errorCode: String, - bankReturnCode: String, - dataTransfer: CryptoUtil.EncryptionResult? = null, - orderId: String? = null -) { - val responseXml = EbicsKeyManagementResponse().apply { - version = "H004" - header = EbicsKeyManagementResponse.Header().apply { - authenticate = true - mutable = EbicsKeyManagementResponse.MutableHeaderType().apply { - reportText = errorText - returnCode = errorCode - if (orderId != null) { - this.orderID = orderId - } - } - _static = EbicsKeyManagementResponse.EmptyStaticHeader() - } - body = EbicsKeyManagementResponse.Body().apply { - this.returnCode = EbicsKeyManagementResponse.ReturnCode().apply { - this.authenticate = true - this.value = bankReturnCode - } - if (dataTransfer != null) { - this.dataTransfer = EbicsKeyManagementResponse.DataTransfer().apply { - this.dataEncryptionInfo = EbicsTypes.DataEncryptionInfo().apply { - this.authenticate = true - this.transactionKey = dataTransfer.encryptedTransactionKey - this.encryptionPubKeyDigest = EbicsTypes.PubKeyDigest().apply { - this.algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" - this.version = "E002" - this.value = dataTransfer.pubKeyDigest - } - } - this.orderData = EbicsKeyManagementResponse.OrderData().apply { - this.value = Base64.getEncoder().encodeToString(dataTransfer.encryptedData) - } - } - } - } - } - val text = XMLUtil.convertJaxbToString(responseXml) - // logger.info("responding with:\n${text}") - if (!XMLUtil.validateFromString(text)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoint EBICS key management response is invalid" - ) - respondText(text, ContentType.Application.Xml, HttpStatusCode.OK) -} - -fun expectNonNull(x: T?): T { - if (x == null) { - throw EbicsProtocolError(HttpStatusCode.BadRequest, "expected non-null value") - } - return x; -} - -private fun getRelatedParty(branch: XmlElementBuilder, payment: XLibeufinBankTransaction) { - val otherParty = object { - var ibanPath = "CdtrAcct/Id/IBAN" - var namePath = "Cdtr/Nm" - var iban = payment.creditorIban - var name = payment.creditorName - var bicPath = "CdtrAgt/FinInstnId/BIC" - var bic = payment.creditorBic - } - if (payment.direction == XLibeufinBankDirection.CREDIT) { - otherParty.iban = payment.debtorIban - otherParty.ibanPath = "DbtrAcct/Id/IBAN" - otherParty.namePath = "Dbtr/Nm" - otherParty.name = payment.debtorName - otherParty.bic = payment.debtorBic - otherParty.bicPath = "DbtrAgt/FinInstnId/BIC" - } - branch.element("RltdPties") { - element(otherParty.namePath) { - text(otherParty.name) - } - element(otherParty.ibanPath) { - text(otherParty.iban) - } - } - val otherPartyBic = otherParty.bic - if (otherPartyBic != null) { - branch.element("RltdAgts") { - element(otherParty.bicPath) { - text(otherPartyBic) - } - } - } -} - -// This should fix #6269. -private fun getCreditDebitInd(balance: BigDecimal): String { - if (balance < BigDecimal.ZERO) return "DBIT" - return "CRDT" -} - -fun buildCamtString( - type: Int, - subscriberIban: String, - history: MutableList, - currency: String -): SandboxCamt { - /** - * ID types required: - * - * - Message Id - * - Statement / Report Id - * - Electronic sequence number - * - Legal sequence number - * - Entry Id by the Servicer - * - Payment information Id - * - Proprietary code of the bank transaction - * - Id of the servicer (Issuer and Code) - */ - val camtCreationTime = getSystemTimeNow() // FIXME: should this be the payment time? - val dashedDate = camtCreationTime.toDashedDate() - val zonedDateTime = camtCreationTime.toZonedString() - val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli() - val messageId = "sandbox-${creationTimeMillis / 1000}-${getRandomString(10)}" - - val camtMessage = constructXml(indent = true) { - root("Document") { - attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02") - attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") - attribute( - "xsi:schemaLocation", - "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02 camt.0${type}.001.02.xsd" - ) - element(if (type == 53) "BkToCstmrStmt" else "BkToCstmrAcctRpt") { - element("GrpHdr") { - element("MsgId") { - text(messageId) - } - element("CreDtTm") { - text(zonedDateTime) - } - } - element(if (type == 52) "Rpt" else "Stmt") { - element("Id") { - text("0") - } - element("ElctrncSeqNb") { - text("0") - } - element("LglSeqNb") { - text("0") - } - element("CreDtTm") { - text(zonedDateTime) - } - element("Acct") { - // mandatory account identifier - element("Id/IBAN") { - text(subscriberIban) - } - element("Ccy") { - text(currency) - } - element("Ownr/Nm") { - text("Debitor/Owner Name") - } - element("Svcr/FinInstnId") { - element("Nm") { - text("Libeufin Bank") - } - element("Othr") { - element("Id") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - } - history.forEach { - this.element("Ntry") { - element("Amt") { - attribute("Ccy", it.currency) - text(it.amount) - } - element("CdtDbtInd") { - text( - if (subscriberIban.equals(it.creditorIban)) - "CRDT" else "DBIT" - ) - } - element("Sts") { - /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.) - * From the original text: - * "Status of an entry on the books of the account servicer" */ - text("BOOK") - } - element("BookgDt/Dt") { - text(dashedDate) - } // date of the booking - element("ValDt/Dt") { - text(dashedDate) - } // date of assets' actual (un)availability - element("AcctSvcrRef") { - text(it.uid) - } - element("BkTxCd") { - /* "Set of elements used to fully identify the type of underlying - * transaction resulting in an entry". */ - element("Domn") { - element("Cd") { - text("PMNT") - } - element("Fmly") { - element("Cd") { - text("ICDT") - } - element("SubFmlyCd") { - text("ESCT") - } - } - } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - element("NtryDtls/TxDtls") { - element("Refs") { - element("MsgId") { - text(it.msgId ?: "NOTPROVIDED") - } - element("PmtInfId") { - text(it.pmtInfId ?: "NOTPROVIDED") - } - element("EndToEndId") { - text(it.endToEndId ?: "NOTPROVIDED") - } - } - element("AmtDtls/TxAmt/Amt") { - attribute("Ccy", currency) - text(it.amount) - } - element("BkTxCd") { - element("Domn") { - element("Cd") { - text("PMNT") - } - element("Fmly") { - element("Cd") { - text("ICDT") - } - element("SubFmlyCd") { - text("ESCT") - } - } - } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - getRelatedParty(this, it) - element("RmtInf/Ustrd") { - text(it.subject) - } - } - } - } - } - } - } - } - return SandboxCamt( - camtMessage = camtMessage, - messageId = messageId, - creationTime = creationTimeMillis - ) -} - -/** - * Builds CAMT response. - * - * @param type 52 or 53. - */ -private fun constructCamtResponse( - type: Int, - subscriber: EbicsSubscriberEntity, - dateRange: Pair? -): List { - if (type != 53 && type != 52) throw EbicsUnsupportedOrderType() - val bankAccount = getBankAccountFromSubscriber(subscriber) - val history = mutableListOf() - if (type == 52) { - if (dateRange != null) { - logger.debug("Finding date-ranged transactions for account: ${bankAccount.label}, range: ${dateRange.first}, ${dateRange.second}") - transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq bankAccount.id and - BankAccountTransactionsTable.date.between( - dateRange.first, dateRange.second - ) - }.forEach { history.add(getHistoryElementFromTransactionRow(it)) } - } - } else - transaction { - BankAccountFreshTransactionEntity.all().forEach { - if (it.transactionRef.account.label == bankAccount.label) { - history.add(getHistoryElementFromTransactionRow(it)) - } - } - } - if (history.size == 0) throw EbicsNoDownloadDataAvailable() - val camtData = buildCamtString( - type, - bankAccount.iban, - history, - bankAccount.demoBank.config.currency - ) - val paymentsList: String = if (logger.isDebugEnabled) { - var ret = " It includes the payments:" - for (p in history) ret += "\n- ${p.subject}" - ret - } else "" - logger.debug("camt.052 document '${camtData.messageId}' generated.$paymentsList") - return listOf(camtData.camtMessage) - } // end of C52 case. - val ret = mutableListOf() - /** - * Retrieve all the records whose creation date lies into the - * time range given in the function parameters. - */ - if (dateRange != null) { - logger.debug("Serving C53 with date range: $dateRange") - BankAccountStatementEntity.find { - BankAccountStatementsTable.creationTime.between( - dateRange.first, - dateRange.second) and( - BankAccountStatementsTable.bankAccount eq bankAccount.id) - }.forEach { - logger.debug("Including Camt.053: ${it.statementId}") - ret.add(it.xmlMessage) - } - } else { - logger.debug("Serving C53 without date range.") - // No time range was given, hence pick the latest statement. - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull().apply { - if (this != null) { - logger.debug("Including Camt.053: ${this.statementId}") - ret.add(this.xmlMessage) - } - } - } - if (ret.size == 0) throw EbicsNoDownloadDataAvailable() - return ret -} - -/** - * TSD (test download) message. - * - * This is a non-standard EBICS order type use by LibEuFin to - * test download transactions. - * - * In the future, additional parameters (size, chunking, inject fault for retry) might - * be added to the order parameters. - */ -private fun handleEbicsTSD(): ByteArray { - return "Hello World\n".repeat(1024).toByteArray() -} - -private fun handleEbicsPTK(): ByteArray { - return "Hello I am a dummy PTK response.".toByteArray() -} - -private fun parsePain001(paymentRequest: String): PainParseResult { - val painDoc = XMLUtil.parseStringIntoDom(paymentRequest) - return destructXml(painDoc) { - requireRootElement("Document") { - requireUniqueChildNamed("CstmrCdtTrfInitn") { - val msgId = requireUniqueChildNamed("GrpHdr") { - requireUniqueChildNamed("MsgId") { focusElement.textContent } - } - requireUniqueChildNamed("PmtInf") { - val debtorName = requireUniqueChildNamed("Dbtr"){ - requireUniqueChildNamed("Nm") { - focusElement.textContent - } - } - val debtorIban = requireUniqueChildNamed("DbtrAcct"){ - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { - focusElement.textContent - } - } - } - val debtorBic = requireUniqueChildNamed("DbtrAgt"){ - requireUniqueChildNamed("FinInstnId") { - requireUniqueChildNamed("BIC") { - focusElement.textContent - } - } - } - val pmtInfId = requireUniqueChildNamed("PmtInfId") { focusElement.textContent } - val txDetails = requireUniqueChildNamed("CdtTrfTxInf") { - object { - val creditorIban = requireUniqueChildNamed("CdtrAcct") { - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { focusElement.textContent } - } - } - val creditorName = requireUniqueChildNamed("Cdtr") { - requireUniqueChildNamed("Nm") { - focusElement.textContent - } - } - val creditorBic = maybeUniqueChildNamed("CdtrAgt") { - requireUniqueChildNamed("FinInstnId") { - requireUniqueChildNamed("BIC") { - focusElement.textContent - } - } - } - val amt = requireUniqueChildNamed("Amt") { - requireOnlyChild { focusElement } - } - val subject = requireUniqueChildNamed("RmtInf") { - requireUniqueChildNamed("Ustrd") { focusElement.textContent } - } - val endToEndId = requireUniqueChildNamed("PmtId") { - requireUniqueChildNamed("EndToEndId") { focusElement.textContent } - } - } - } - /** - * NOTE: this check breaks the compatibility with pain.001, - * because that allows up to 5 fractional digits. For Taler - * compatibility however, we enforce the max 2 fractional digits policy. - */ - if (!validatePlainAmount(txDetails.amt.textContent)) { - throw EbicsProcessingError( - "Amount number malformed: ${txDetails.amt.textContent}" - ) - } - PainParseResult( - currency = txDetails.amt.getAttribute("Ccy"), - amount = txDetails.amt.textContent, - subject = txDetails.subject, - debtorIban = debtorIban, - debtorName = debtorName, - debtorBic = debtorBic, - creditorName = txDetails.creditorName, - creditorIban = txDetails.creditorIban, - creditorBic = txDetails.creditorBic, - pmtInfId = pmtInfId, - endToEndId = txDetails.endToEndId, - msgId = msgId - ) - } - } - } - } -} - -/** - * Process a payment request in the pain.001 format. Note: - * the receiver IBAN is NOT checked to have one account at - * the Sandbox. That's because (1) it leaves open to send - * payments outside of the running Sandbox and (2) may ease - * tests where the preparation logic can skip creating also - * the receiver account. */ -private fun handleCct( - paymentRequest: String, - requestingSubscriber: EbicsSubscriberEntity -) { - val parseResult = parsePain001(paymentRequest) - logger.debug("Handling Pain.001: ${parseResult.pmtInfId}, " + - "for payment: ${parseResult.subject}") - transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { - // Check that subscriber has a bank account - // and that they have rights over the debtor IBAN - if (requestingSubscriber.bankAccount == null) throw EbicsProcessingError( - "Subscriber '${requestingSubscriber.userId}' does not have a bank account." - ) - if (requestingSubscriber.bankAccount!!.iban != parseResult.debtorIban) throw - EbicsAccountAuthorisationFailed( - "Subscriber '${requestingSubscriber.userId}' does not have rights" + - " over the debtor IBAN '${parseResult.debtorIban}'" - ) - val maybeExist = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.pmtInfId eq parseResult.pmtInfId - }.firstOrNull() - if (maybeExist != null) { - logger.info( - "Nexus submitted twice the Pain: ${maybeExist.pmtInfId}. Not taking any action." + - " Sandbox gave it this reference: ${maybeExist.accountServicerReference}" - ) - return@transaction - } - val bankAccount = getBankAccountFromIban(parseResult.debtorIban) - if (parseResult.currency != bankAccount.demoBank.config.currency) throw EbicsRequestError( - "[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not supported.", - "091116" - ) - // Check for the debit case. - val maybeAmount = try { - BigDecimal(parseResult.amount) - } catch (e: Exception) { - logger.warn("Although PAIN validated, BigDecimal didn't parse its amount (${parseResult.amount})!") - throw EbicsProcessingError("The CCT request contains an invalid amount: ${parseResult.amount}") - } - if (maybeDebit(bankAccount.label, maybeAmount, bankAccount.demoBank.name)) - throw EbicsAmountCheckError("The requested amount (${parseResult.amount}) would exceed the debit threshold") - logger.debug("Wire-transfer'ing endToEndId: ${parseResult.endToEndId}") - wireTransfer( - bankAccount.label, - getBankAccountFromIban(parseResult.creditorIban).label, - bankAccount.demoBank.name, - parseResult.subject, - "${parseResult.currency}:${parseResult.amount}", - endToEndId = parseResult.endToEndId - ) - } -} - -/** - * This handler reports all the fresh transactions, belonging - * to the querying subscriber. - */ -private fun handleEbicsC52(requestContext: RequestContext): ByteArray { - val maybeDateRange = requestContext.requestObject.header.static.orderDetails?.orderParams - val dateRange: Pair? = if (maybeDateRange is EbicsRequest.StandardOrderParams) { - val start: Long? = maybeDateRange.dateRange?.start?.toGregorianCalendar()?.timeInMillis - val end: Long? = maybeDateRange.dateRange?.end?.toGregorianCalendar()?.timeInMillis - Pair(start ?: 0L, end ?: Long.MAX_VALUE) - } else null - logger.debug("Date range: $dateRange") - val report = constructCamtResponse( - 52, - requestContext.subscriber, - dateRange = dateRange - ) - sandboxAssert( - report.size == 1, - "C52 response contains more than one Camt.052 document" - ) - if (!XMLUtil.validateFromString(report[0])) { - logger.error("This document was generated invalid:\n${report[0]}") - throw EbicsProcessingError("One outgoing report was found invalid.") - } - return report.map { it.toByteArray() }.zip() -} - -private fun handleEbicsC53(requestContext: RequestContext): ByteArray { - // Fetch date range. - val orderParams = requestContext.requestObject.header.static.orderDetails?.orderParams // as EbicsRequest.StandardOrderParams - val dateRange = if (orderParams != null) { - val standardOrderParams = orderParams as EbicsRequest.StandardOrderParams - val start = standardOrderParams.dateRange?.start?.toGregorianCalendar()?.timeInMillis - val end = standardOrderParams.dateRange?.end?.toGregorianCalendar()?.timeInMillis - if (start == null || end == null) { - // only accepting when both start/end are given. - null - } else { - Pair(start, end) - } - } else - null - /** - * By multiple statements, this function is responsible to return - * a list of Strings: one for each statement. - */ - val camtStatements = constructCamtResponse( - 53, - requestContext.subscriber, - dateRange - ) - camtStatements.forEach { - if (!XMLUtil.validateFromString(it)) { - logger.error("This document was generated invalid:\n$it") - throw EbicsProcessingError("One outgoing statement was found invalid.") - } - } - return camtStatements.map { it.toByteArray() }.zip() -} - -private suspend fun ApplicationCall.handleEbicsHia(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { - InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } - val keyObject = EbicsOrderUtil.decodeOrderDataXml(orderData) - val encPubXml = keyObject.encryptionPubKeyInfo.pubKeyValue.rsaKeyValue - val authPubXml = keyObject.authenticationPubKeyInfo.pubKeyValue.rsaKeyValue - val encPub = CryptoUtil.loadRsaPublicKeyFromComponents(encPubXml.modulus, encPubXml.exponent) - val authPub = CryptoUtil.loadRsaPublicKeyFromComponents(authPubXml.modulus, authPubXml.exponent) - - val ok = transaction { - val ebicsSubscriber = findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - logger.warn("ebics subscriber not found") - throw EbicsInvalidRequestError() - } - when (ebicsSubscriber.state) { - SubscriberState.NEW -> {} - SubscriberState.PARTIALLY_INITIALIZED_INI -> {} - SubscriberState.PARTIALLY_INITIALIZED_HIA, SubscriberState.INITIALIZED, SubscriberState.READY -> { - return@transaction false - } - } - - ebicsSubscriber.authenticationKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(authPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.encryptionKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(encPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.state = when (ebicsSubscriber.state) { - SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_HIA - SubscriberState.PARTIALLY_INITIALIZED_INI -> SubscriberState.INITIALIZED - else -> throw Exception("internal invariant failed") - } - return@transaction true - } - if (ok) { - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") - } else { - respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") - } -} - -private suspend fun ApplicationCall.handleEbicsIni(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { - InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } - val keyObject = EbicsOrderUtil.decodeOrderDataXml(orderData) - val sigPubXml = keyObject.signaturePubKeyInfo.pubKeyValue.rsaKeyValue - val sigPub = CryptoUtil.loadRsaPublicKeyFromComponents(sigPubXml.modulus, sigPubXml.exponent) - - val ok = transaction { - val ebicsSubscriber = - findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - logger.warn("ebics subscriber, ${dumpEbicsSubscriber(header.static)}, not found") - throw EbicsUserUnknown(dumpEbicsSubscriber(header.static)) - } - when (ebicsSubscriber.state) { - SubscriberState.NEW -> {} - SubscriberState.PARTIALLY_INITIALIZED_HIA -> {} - SubscriberState.PARTIALLY_INITIALIZED_INI, SubscriberState.INITIALIZED, SubscriberState.READY -> { - return@transaction false - } - } - ebicsSubscriber.signatureKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(sigPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.state = when (ebicsSubscriber.state) { - SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_INI - SubscriberState.PARTIALLY_INITIALIZED_HIA -> SubscriberState.INITIALIZED - else -> throw Error("internal invariant failed") - } - return@transaction true - } - logger.info("Signature key inserted in database _and_ subscriber state changed accordingly") - if (ok) { - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") - } else { - respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") - } -} - -private suspend fun ApplicationCall.handleEbicsHpb( - ebicsHostInfo: EbicsHostPublicInfo, - requestDocument: Document, - header: EbicsNpkdRequest.Header -) { - val subscriberKeys = transaction { - val ebicsSubscriber = - findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - throw EbicsInvalidRequestError() - } - if (ebicsSubscriber.state != SubscriberState.INITIALIZED) { - throw EbicsSubscriberStateError() - } - val authPubBlob = ebicsSubscriber.authenticationKey!!.rsaPublicKey - val encPubBlob = ebicsSubscriber.encryptionKey!!.rsaPublicKey - val sigPubBlob = ebicsSubscriber.signatureKey!!.rsaPublicKey - SubscriberKeys( - CryptoUtil.loadRsaPublicKey(authPubBlob.bytes), - CryptoUtil.loadRsaPublicKey(encPubBlob.bytes), - CryptoUtil.loadRsaPublicKey(sigPubBlob.bytes) - ) - } - val validationResult = - XMLUtil.verifyEbicsDocument(requestDocument, subscriberKeys.authenticationPublicKey) - if (!validationResult) { - throw EbicsKeyManagementError("invalid signature", "90000") - } - val hpbRespondeData = HPBResponseOrderData().apply { - this.authenticationPubKeyInfo = EbicsTypes.AuthenticationPubKeyInfoType().apply { - this.authenticationVersion = "X002" - this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { - this.rsaKeyValue = RSAKeyValueType().apply { - this.exponent = ebicsHostInfo.authenticationPublicKey.publicExponent.toByteArray() - this.modulus = ebicsHostInfo.authenticationPublicKey.modulus.toByteArray() - } - } - } - this.encryptionPubKeyInfo = EbicsTypes.EncryptionPubKeyInfoType().apply { - this.encryptionVersion = "E002" - this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { - this.rsaKeyValue = RSAKeyValueType().apply { - this.exponent = ebicsHostInfo.encryptionPublicKey.publicExponent.toByteArray() - this.modulus = ebicsHostInfo.encryptionPublicKey.modulus.toByteArray() - } - } - } - this.hostID = ebicsHostInfo.hostID - } - val compressedOrderData = EbicsOrderUtil.encodeOrderDataXml(hpbRespondeData) - val encryptionResult = CryptoUtil.encryptEbicsE002(compressedOrderData, subscriberKeys.encryptionPublicKey) - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000", encryptionResult, "OR01") -} - -/** - * Find the ebics host corresponding to the one specified in the header. - */ -private fun ensureEbicsHost(requestHostID: String): EbicsHostPublicInfo { - return transaction { - val ebicsHost = - EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestHostID.uppercase(Locale.getDefault()) }.firstOrNull() - if (ebicsHost == null) { - logger.warn("client requested unknown HostID ${requestHostID}") - throw EbicsKeyManagementError("[EBICS_INVALID_HOST_ID]", "091011") - } - val encryptionPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.encryptionPrivateKey.bytes) - val authenticationPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.authenticationPrivateKey.bytes) - EbicsHostPublicInfo( - requestHostID, - CryptoUtil.getRsaPublicFromPrivate(encryptionPrivateKey), - CryptoUtil.getRsaPublicFromPrivate(authenticationPrivateKey) - ) - } -} -fun receiveEbicsXmlInternal(xmlData: String): Document { - // logger.debug("Data received: $xmlData") - val requestDocument: Document = XMLUtil.parseStringIntoDom(xmlData) - if (!XMLUtil.validateFromDom(requestDocument)) { - println("Problematic document was: $requestDocument") - throw EbicsInvalidXmlError() - } - return requestDocument -} - -private fun makePartnerInfo(subscriber: EbicsSubscriberEntity): EbicsTypes.PartnerInfo { - val bankAccount = getBankAccountFromSubscriber(subscriber) - val customerProfile = getCustomer(bankAccount.label) - return EbicsTypes.PartnerInfo().apply { - this.accountInfoList = listOf( - EbicsTypes.AccountInfo().apply { - this.id = bankAccount.label - this.accountHolder = customerProfile.name ?: "Never Given" - this.accountNumberList = listOf( - EbicsTypes.GeneralAccountNumber().apply { - this.international = true - this.value = bankAccount.iban - } - ) - this.currency = bankAccount.demoBank.config.currency - this.description = "Ordinary Bank Account" - this.bankCodeList = listOf( - EbicsTypes.GeneralBankCode().apply { - this.international = true - this.value = bankAccount.bic - } - ) - } - ) - this.addressInfo = EbicsTypes.AddressInfo().apply { - this.name = "Address Info Object" - } - this.bankInfo = EbicsTypes.BankInfo().apply { - this.hostID = subscriber.hostId - } - this.orderInfoList = listOf( - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Transactions statement" - this.orderType = "C53" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Transactions report" - this.orderType = "C52" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Payment initiation (ZIPped payload)" - this.orderType = "CCC" - this.transferType = "Upload" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Payment initiation (plain text payload)" - this.orderType = "CCT" - this.transferType = "Upload" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "vmk" - this.orderType = "VMK" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "sta" - this.orderType = "STA" - this.transferType = "Download" - } - ) - } -} - -private fun handleEbicsHtd(requestContext: RequestContext): ByteArray { - val htd = HTDResponseOrderData().apply { - this.partnerInfo = makePartnerInfo(requestContext.subscriber) - this.userInfo = EbicsTypes.UserInfo().apply { - this.name = "Some User" - this.userID = EbicsTypes.UserIDType().apply { - this.status = 5 - this.value = requestContext.subscriber.userId - } - this.permissionList = listOf( - EbicsTypes.UserPermission().apply { - this.orderTypes = "C53 C52 CCC VMK STA" - } - ) - } - } - val str = XMLUtil.convertJaxbToString(htd) - return str.toByteArray() -} - -private fun handleEbicsHkd(requestContext: RequestContext): ByteArray { - val hkd = HKDResponseOrderData().apply { - this.partnerInfo = makePartnerInfo(requestContext.subscriber) - this.userInfoList = listOf( - EbicsTypes.UserInfo().apply { - this.name = "Some User" - this.userID = EbicsTypes.UserIDType().apply { - this.status = 1 - this.value = requestContext.subscriber.userId - } - this.permissionList = listOf( - EbicsTypes.UserPermission().apply { - this.orderTypes = "C54 C53 C52 CCC" - } - ) - }) - } - val str = XMLUtil.convertJaxbToString(hkd) - return str.toByteArray() -} - -private data class RequestContext( - val ebicsHost: EbicsHostEntity, - val subscriber: EbicsSubscriberEntity, - val clientEncPub: RSAPublicKey, - val clientAuthPub: RSAPublicKey, - val clientSigPub: RSAPublicKey, - val hostEncPriv: RSAPrivateCrtKey, - val hostAuthPriv: RSAPrivateCrtKey, - val requestObject: EbicsRequest, - val uploadTransaction: EbicsUploadTransactionEntity?, - val downloadTransaction: EbicsDownloadTransactionEntity? -) - -/** - * Get segmentation values and the EBICS transaction ID, before - * handing the response to 'createForDownloadTransferPhase()'. - */ -private fun handleEbicsDownloadTransactionTransfer(requestContext: RequestContext): EbicsResponse { - val segmentNumber = - requestContext.requestObject.header.mutable.segmentNumber?.value ?: throw EbicsInvalidRequestError() - val transactionID = requestContext.requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - val downloadTransaction = requestContext.downloadTransaction ?: throw AssertionError() - return EbicsResponse.createForDownloadTransferPhase( - transactionID, - downloadTransaction.numSegments, - downloadTransaction.segmentSize, - downloadTransaction.encodedResponse, - segmentNumber.toInt() - ) -} - -private fun handleEbicsDownloadTransactionInitialization(requestContext: RequestContext): EbicsResponse { - val orderType = - requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() - val nonce = requestContext.requestObject.header.static.nonce - val transactionID = EbicsOrderUtil.generateTransactionId() - logger.debug( - "Handling download initialization for order type $orderType, " + - "nonce: ${nonce?.toHexString() ?: "not given"}, " + - "transaction ID: $transactionID" - ) - val response = when (orderType) { - "HTD" -> handleEbicsHtd(requestContext) - "HKD" -> handleEbicsHkd(requestContext) - "C53" -> handleEbicsC53(requestContext) - "C52" -> handleEbicsC52(requestContext) - "TSD" -> handleEbicsTSD() - "PTK" -> handleEbicsPTK() - else -> throw EbicsInvalidXmlError() - } - val compressedResponse = DeflaterInputStream(response.inputStream()).use { - it.readAllBytes() - } - val enc = CryptoUtil.encryptEbicsE002(compressedResponse, requestContext.clientEncPub) - val encodedResponse = Base64.getEncoder().encodeToString(enc.encryptedData) - - val segmentSize = 4096 - val totalSize = encodedResponse.length - val numSegments = ((totalSize + segmentSize - 1) / segmentSize) - - EbicsDownloadTransactionEntity.new(transactionID) { - this.subscriber = requestContext.subscriber - this.host = requestContext.ebicsHost - this.orderType = orderType - this.segmentSize = segmentSize - this.transactionKeyEnc = ExposedBlob(enc.encryptedTransactionKey) - this.encodedResponse = encodedResponse - this.numSegments = numSegments - this.receiptReceived = false - } - /** - * In case of C52, the payload (that includes all the pending - * transactions) got at this point persisted into the database. - * The next block causes such transactions NOT to be returned - * along the next C52 request. - */ - if (orderType == "C52") { - val account = getBankAccountFromSubscriber(requestContext.subscriber) - BankAccountFreshTransactionEntity.all().forEach { - if (it.transactionRef.account.label == account.label) - it.delete() - } - } - return EbicsResponse.createForDownloadInitializationPhase( - transactionID, - numSegments, - segmentSize, - enc, // has customer key - encodedResponse - ) -} - -private fun handleEbicsUploadTransactionInitialization(requestContext: RequestContext): EbicsResponse { - val orderType = - requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() - val transactionID = EbicsOrderUtil.generateTransactionId() - logger.debug("Handling upload initialization for order $orderType, " + - "transactionID $transactionID, nonce: " + - (requestContext.requestObject.header.static.nonce?.toHexString() ?: "not given") - ) - val oidn = requestContext.subscriber.nextOrderID++ - if (EbicsOrderUtil.checkOrderIDOverflow(oidn)) throw NotImplementedError() - val orderID = EbicsOrderUtil.computeOrderIDFromNumber(oidn) - val numSegments = - requestContext.requestObject.header.static.numSegments ?: throw EbicsInvalidRequestError() - val transactionKeyEnc = - requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.transactionKey - ?: throw EbicsInvalidRequestError() - val encPubKeyDigest = - requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.encryptionPubKeyDigest?.value - ?: throw EbicsInvalidRequestError() - val encSigData = requestContext.requestObject.body.dataTransfer?.signatureData?.value - ?: throw EbicsInvalidRequestError() - val decryptedSignatureData = CryptoUtil.decryptEbicsE002( - CryptoUtil.EncryptionResult( - transactionKeyEnc, - encPubKeyDigest, - encSigData - ), requestContext.hostEncPriv - ) - val plainSigData = InflaterInputStream(decryptedSignatureData.inputStream()).use { - it.readAllBytes() - } - EbicsUploadTransactionEntity.new(transactionID) { - this.host = requestContext.ebicsHost - this.subscriber = requestContext.subscriber - this.lastSeenSegment = 0 - this.orderType = orderType - this.orderID = orderID - this.numSegments = numSegments.toInt() - this.transactionKeyEnc = ExposedBlob(transactionKeyEnc) - } - val sigObj = XMLUtil.convertStringToJaxb(plainSigData.toString(Charsets.UTF_8)) - for (sig in sigObj.value.orderSignatureList ?: listOf()) { - logger.debug("inserting order signature for orderID $orderID, order type $orderType, transaction '$transactionID'") - EbicsOrderSignatureEntity.new { - this.orderID = orderID - this.orderType = orderType - this.partnerID = sig.partnerID - this.userID = sig.userID - this.signatureAlgorithm = sig.signatureVersion - this.signatureValue = ExposedBlob(sig.signatureValue) - } - } - return EbicsResponse.createForUploadInitializationPhase(transactionID, orderID) -} - -private fun handleEbicsUploadTransactionTransmission(requestContext: RequestContext): EbicsResponse { - val uploadTransaction = requestContext.uploadTransaction ?: throw EbicsInvalidRequestError() - val requestObject = requestContext.requestObject - val requestSegmentNumber = - requestContext.requestObject.header.mutable.segmentNumber?.value?.toInt() ?: throw EbicsInvalidRequestError() - val requestTransactionID = requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - if (requestSegmentNumber == 1 && uploadTransaction.numSegments == 1) { - val encOrderData = - requestObject.body.dataTransfer?.orderData ?: throw EbicsInvalidRequestError() - val zippedData = CryptoUtil.decryptEbicsE002( - uploadTransaction.transactionKeyEnc.bytes, - Base64.getDecoder().decode(encOrderData), - requestContext.hostEncPriv - ) - val unzippedData = - InflaterInputStream(zippedData.inputStream()).use { it.readAllBytes() } - - val sigs = EbicsOrderSignatureEntity.find { - (EbicsOrderSignaturesTable.orderID eq uploadTransaction.orderID) and - (EbicsOrderSignaturesTable.orderType eq uploadTransaction.orderType) - } - if (sigs.count() == 0L) { - throw EbicsInvalidRequestError() - } - for (sig in sigs) { - if (sig.signatureAlgorithm == "A006") { - - val signedData = CryptoUtil.digestEbicsOrderA006(unzippedData) - val res1 = CryptoUtil.verifyEbicsA006( - sig.signatureValue.bytes, - signedData, - requestContext.clientSigPub - ) - if (!res1) { - throw EbicsInvalidRequestError() - } - - } else { - throw NotImplementedError() - } - } - if (getOrderTypeFromTransactionId(requestTransactionID) == "CCT") { - handleCct(unzippedData.toString(Charsets.UTF_8), - requestContext.subscriber - ) - } - return EbicsResponse.createForUploadTransferPhase( - requestTransactionID, - requestSegmentNumber, - true, - uploadTransaction.orderID - ) - } else { - throw NotImplementedError() - } -} -// req.header.static.hostID. -private fun makeRequestContext(requestObject: EbicsRequest): RequestContext { - val staticHeader = requestObject.header.static - val requestedHostId = staticHeader.hostID - val ebicsHost = - EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestedHostId.uppercase(Locale.getDefault()) } - .firstOrNull() - val requestTransactionID = requestObject.header.static.transactionID - var downloadTransaction: EbicsDownloadTransactionEntity? = null - var uploadTransaction: EbicsUploadTransactionEntity? = null - val subscriber = if (requestTransactionID != null) { - downloadTransaction = EbicsDownloadTransactionEntity.findById(requestTransactionID.uppercase(Locale.getDefault())) - if (downloadTransaction != null) { - downloadTransaction.subscriber - } else { - uploadTransaction = EbicsUploadTransactionEntity.findById(requestTransactionID) - uploadTransaction?.subscriber - } - } else { - val partnerID = staticHeader.partnerID ?: throw EbicsInvalidRequestError() - val userID = staticHeader.userID ?: throw EbicsInvalidRequestError() - findEbicsSubscriber(partnerID, userID, staticHeader.systemID) - } - - if (ebicsHost == null) throw EbicsInvalidRequestError() - - /** - * NOTE: production logic must check against READY state (the - * one activated after the subscriber confirms their keys via post) - */ - if (subscriber == null || subscriber.state != SubscriberState.INITIALIZED) - throw EbicsSubscriberStateError() - - val hostAuthPriv = CryptoUtil.loadRsaPrivateKey( - ebicsHost.authenticationPrivateKey.bytes - ) - val hostEncPriv = CryptoUtil.loadRsaPrivateKey( - ebicsHost.encryptionPrivateKey.bytes - ) - val clientAuthPub = - CryptoUtil.loadRsaPublicKey(subscriber.authenticationKey!!.rsaPublicKey.bytes) - val clientEncPub = - CryptoUtil.loadRsaPublicKey(subscriber.encryptionKey!!.rsaPublicKey.bytes) - val clientSigPub = - CryptoUtil.loadRsaPublicKey(subscriber.signatureKey!!.rsaPublicKey.bytes) - - return RequestContext( - hostAuthPriv = hostAuthPriv, - hostEncPriv = hostEncPriv, - clientAuthPub = clientAuthPub, - clientEncPub = clientEncPub, - clientSigPub = clientSigPub, - ebicsHost = ebicsHost, - requestObject = requestObject, - subscriber = subscriber, - downloadTransaction = downloadTransaction, - uploadTransaction = uploadTransaction - ) -} - -suspend fun ApplicationCall.ebicsweb() { - val requestDocument = this.request.call.receive() - val requestedHostID = requestDocument.getElementsByTagName("HostID") - this.attributes.put( - EbicsHostIdAttribute, - requestedHostID.item(0).textContent - ) - when (requestDocument.documentElement.localName) { - "ebicsUnsecuredRequest" -> { - val requestObject = requestDocument.toObject() - logger.info("Serving a ${requestObject.header.static.orderDetails.orderType} request") - - val orderData = requestObject.body.dataTransfer.orderData.value - val header = requestObject.header - - when (header.static.orderDetails.orderType) { - "INI" -> handleEbicsIni(header, orderData) - "HIA" -> handleEbicsHia(header, orderData) - else -> throw EbicsInvalidXmlError() - } - } - "ebicsHEVRequest" -> { - val hevResponse = HEVResponse().apply { - this.systemReturnCode = SystemReturnCodeType().apply { - this.reportText = "[EBICS_OK]" - this.returnCode = "000000" - } - this.versionNumber = listOf(HEVResponse.VersionNumber.create("H004", "02.50")) - } - - val strResp = XMLUtil.convertJaxbToString(hevResponse) - if (!XMLUtil.validateFromString(strResp)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoing HEV response is invalid" - ) - respondText(strResp, ContentType.Application.Xml, HttpStatusCode.OK) - } - // FIXME: should check subscriber state? - "ebicsNoPubKeyDigestsRequest" -> { - val requestObject = requestDocument.toObject() - val hostInfo = ensureEbicsHost(requestObject.header.static.hostID) - when (requestObject.header.static.orderDetails.orderType) { - "HPB" -> handleEbicsHpb(hostInfo, requestDocument, requestObject.header) - else -> throw EbicsInvalidXmlError() - } - } - // FIXME: must check subscriber state. - "ebicsRequest" -> { - val requestObject = requestDocument.toObject() - val responseXmlStr = transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { - // Step 1 of 3: Get information about the host and subscriber - val requestContext = makeRequestContext(requestObject) - // Step 2 of 3: Validate the signature - val verifyResult = XMLUtil.verifyEbicsDocument(requestDocument, requestContext.clientAuthPub) - if (!verifyResult) { - throw EbicsAccountAuthorisationFailed("Subscriber's signature did not verify") - } - // Step 3 of 3: Generate response - val ebicsResponse: EbicsResponse = when (requestObject.header.mutable.transactionPhase) { - EbicsTypes.TransactionPhaseType.INITIALISATION -> { - if (requestObject.header.static.numSegments == null) { - handleEbicsDownloadTransactionInitialization(requestContext) - } else { - handleEbicsUploadTransactionInitialization(requestContext) - } - } - EbicsTypes.TransactionPhaseType.TRANSFER -> { - if (requestContext.uploadTransaction != null) { - handleEbicsUploadTransactionTransmission(requestContext) - } else if (requestContext.downloadTransaction != null) { - handleEbicsDownloadTransactionTransfer(requestContext) - } else { - throw AssertionError() - } - } - EbicsTypes.TransactionPhaseType.RECEIPT -> { - val requestTransactionID = - requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - if (requestContext.downloadTransaction == null) - throw EbicsInvalidRequestError() - logger.debug("Handling download receipt for EBICS transaction: " + - requestTransactionID) - /** - * The receipt phase means that the client has already - * received all the data related to the current download - * transaction. Hence this data can now be removed from - * the database. - */ - val ebicsData = transaction { - EbicsDownloadTransactionEntity.findById(requestTransactionID) - } - if (ebicsData == null) - throw SandboxError( - HttpStatusCode.InternalServerError, - "EBICS transaction $requestTransactionID was not" + - "found in the database for deletion.", - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE - ) - ebicsData.delete() - val receiptCode = - requestObject.body.transferReceipt?.receiptCode ?: throw EbicsInvalidRequestError() - EbicsResponse.createForDownloadReceiptPhase(requestTransactionID, receiptCode == 0) - } - } - signEbicsResponse(ebicsResponse, requestContext.hostAuthPriv) - } - if (!XMLUtil.validateFromString(responseXmlStr)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoing EBICS XML is invalid" - ) - respondText(responseXmlStr, ContentType.Application.Xml, HttpStatusCode.OK) - } - else -> { - /* Log to console and return "unknown type" */ - logger.info("Unknown message, just logging it!") - respond( - HttpStatusCode.NotImplemented, - SandboxError( - HttpStatusCode.NotImplemented, - "Not Implemented" - ) - ) - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt deleted file mode 100644 index 6529b9d1..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt +++ /dev/null @@ -1,472 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * - */ - -package tech.libeufin.sandbox - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import io.ktor.server.application.* -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.security.interfaces.RSAPublicKey -import java.util.* -import java.util.zip.DeflaterInputStream -import kotlin.reflect.KProperty - -data class DemobankConfig( - val allowRegistrations: Boolean, - val currency: String, - val cashoutCurrency: String? = null, - val bankDebtLimit: Int, - val usersDebtLimit: Int, - val withSignupBonus: Boolean, - val demobankName: String, // demobank name. - val captchaUrl: String? = null, - val smsTan: String? = null, // fixme: move the config subcommand - val emailTan: String? = null, // fixme: same as above. - val suggestedExchangeBaseUrl: String? = null, - val suggestedExchangePayto: String? = null, - val nexusBaseUrl: String? = null, - val usernameAtNexus: String? = null, - val passwordAtNexus: String? = null, - val enableConversionService: Boolean = false -) - -fun getConfigValueOrThrow(configKey: KProperty): T { - return configKey.getter.call() ?: throw nullConfigValueError(configKey.name) -} - -/** - * Helps to communicate Camt values without having - * to parse the XML each time one is needed. - */ -data class SandboxCamt( - val camtMessage: String, - val messageId: String, - /** - * That is the number of SECONDS since Epoch. This - * value is exactly what goes into the Camt document. - */ - val creationTime: Long -) - -/** - * DB helper inserting a new "account" into the database. - * The account is made of a 'customer' and 'bank account' - * object. The helper checks first that the username is - * acceptable (chars, no institutional names, available - * names); then checks that IBAN is available and then adds - * the two database objects under the given demobank. This - * function contains the common logic shared by the Access - * and Circuit API. Additional data that is peculiar to one - * API should be added separately. - * - * It returns a AccountPair type. That contains the customer - * object and the bank account; the caller may this way add custom - * values to them. */ -data class AccountPair( - val customer: DemobankCustomerEntity, - val bankAccount: BankAccountEntity -) -fun insertNewAccount(username: String, - password: String, - name: String? = null, // tests and access API may not give one. - iban: String? = null, - demobank: String = "default", - isPublic: Boolean = false): AccountPair { - requireValidResourceName(username) - // Forbid institutional usernames. - if (username == "bank" || username == "admin") { - logger.info("Username: $username not allowed.") - throw forbidden("Username: $username is not allowed.") - } - return transaction { - val demobankFromDb = getDemobank(demobank) - // Bank's fault, because when this function gets - // called, the demobank must exist. - if (demobankFromDb == null) { - logger.error("Demobank '$demobank' not found. Won't add account $username") - throw internalServerError("Demobank $demobank not found. Won't add account $username") - } - // Generate a IBAN if the caller didn't provide one. - val newIban = iban ?: getIban() - // Check IBAN collisions. - val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq newIban).firstOrNull() - if (checkIbanExist != null) { - logger.info("IBAN $newIban not available. Won't register username $username") - throw conflict("IBAN $iban not available.") - } - // Check username availability. - val checkCustomerExist = DemobankCustomerEntity.find { - DemobankCustomersTable.username eq username - }.firstOrNull() - if (checkCustomerExist != null) { - throw SandboxError( - HttpStatusCode.Conflict, - "Username $username not available." - ) - } - val newCustomer = DemobankCustomerEntity.new { - this.username = username - passwordHash = CryptoUtil.hashpw(password) - this.name = name // nullable - } - // Actual account creation. - val newBankAccount = BankAccountEntity.new { - this.iban = newIban - /** - * For now, keep same semantics of Pybank: a username - * is AS WELL a bank account label. In other words, it - * identifies a customer AND a bank account. The reason - * to have the two values (label and owner) is to allow - * multiple bank accounts being owned by one customer. - */ - label = username - owner = username - this.demoBank = demobankFromDb - this.isPublic = isPublic - } - if (demobankFromDb.config.withSignupBonus) - newBankAccount.bonus("${demobankFromDb.config.currency}:100") - AccountPair(customer = newCustomer, bankAccount = newBankAccount) - } -} - -/** - * Return true if access to the bank account can be granted, - * false otherwise. - * - * Given the policy of having bank account names matching - * their owner's username, this function enforces such policy - * with the exception that 'admin' can access every bank - * account. A null username indicates disabled authentication - * checks, hence it grants the access. - */ -fun allowOwnerOrAdmin(username: String?, bankAccountLabel: String): Boolean { - if (username == null) return true - if (username == "admin") return true - return username == bankAccountLabel -} - -/** - * Throws exception if the credentials are wrong. - * - * Return: - * - null if the authentication is disabled (during tests, for example). - * This facilitates tests because allows requests to lack entirely an - * Authorization header. - * - the username of the authenticated user - * - throw exception when the authentication fails - * - * Note: at this point it is ONLY checked whether the user provided - * a valid password for the username mentioned in the Authorization header. - * The actual access to the resources must be later checked by each handler. - */ -fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = false): String? { - val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY) - if (!withAuth) { - logger.info("Authentication is disabled - assuming tests currently running.") - return null - } - val credentials = getHTTPBasicAuthCredentials(this) - if (credentials.first == "admin") { - // env must contain the admin password, because --with-auth is true. - val adminPassword: String = this.call.ensureAttribute(ADMIN_PASSWORD_ATTRIBUTE_KEY) - if (credentials.second != adminPassword) throw unauthorized( - "Admin authentication failed" - ) - return credentials.first - } - if (onlyAdmin) throw forbidden("Only admin allowed.") - val passwordHash = transaction { - val customer = getCustomer(credentials.first) - customer.passwordHash - } - if (!CryptoUtil.checkPwOrThrow(credentials.second, passwordHash)) - throw unauthorized("Customer '${credentials.first}' gave wrong credentials") - return credentials.first -} - -fun sandboxAssert(condition: Boolean, reason: String) { - if (!condition) throw SandboxError(HttpStatusCode.InternalServerError, reason) -} - -fun getOrderTypeFromTransactionId(transactionID: String): String { - val uploadTransaction = transaction { - EbicsUploadTransactionEntity.findById(transactionID) - } ?: throw SandboxError( - /** - * NOTE: at this point, it might even be the server's fault. - * For example, if it failed to store a ID earlier. - */ - HttpStatusCode.NotFound, - "Could not retrieve order type for transaction: $transactionID" - ) - return uploadTransaction.orderType -} - -fun getHistoryElementFromTransactionRow(dbRow: BankAccountTransactionEntity): XLibeufinBankTransaction { - return XLibeufinBankTransaction( - subject = dbRow.subject, - creditorIban = dbRow.creditorIban, - creditorBic = dbRow.creditorBic, - creditorName = dbRow.creditorName, - debtorIban = dbRow.debtorIban, - debtorBic = dbRow.debtorBic, - debtorName = dbRow.debtorName, - date = dbRow.date.toString(), - amount = dbRow.amount, - currency = dbRow.currency, - // UID assigned by the bank itself. - uid = dbRow.accountServicerReference, - direction = XLibeufinBankDirection.convertCamtDirectionToXLibeufin(dbRow.direction), - // UIDs as gotten from a pain.001 (from EBICS connections.) - pmtInfId = dbRow.pmtInfId, - endToEndId = dbRow.endToEndId - ) -} - -fun printConfig(demobank: DemobankConfigEntity) { - val ret = ObjectMapper() - ret.configure(SerializationFeature.INDENT_OUTPUT, true) - println( - ret.writeValueAsString(object { - val currency = demobank.config.currency - val bankDebtLimit = demobank.config.bankDebtLimit - val usersDebtLimit = demobank.config.usersDebtLimit - val allowRegistrations = demobank.config.allowRegistrations - val name = demobank.name // always 'default' - val withSignupBonus = demobank.config.withSignupBonus - val captchaUrl = demobank.config.captchaUrl - val suggestedExchangeBaseUrl = demobank.config.suggestedExchangeBaseUrl - val suggestedExchangePayto = demobank.config.suggestedExchangePayto - }) - ) -} - -fun getHistoryElementFromTransactionRow( - dbRow: BankAccountFreshTransactionEntity -): XLibeufinBankTransaction { - return getHistoryElementFromTransactionRow(dbRow.transactionRef) -} - -/** - * Need to be called within a transaction {} block. It - * is acceptable to pass a bank account's label as the - * parameter, because usernames can only own one bank - * account whose label equals the owner's username. - * - * Future versions may relax this policy to allow one - * customer to own multiple bank accounts. - */ -fun getCustomer(username: String): DemobankCustomerEntity { - return maybeGetCustomer(username) ?: throw notFound("Customer '${username}' not found") -} -fun maybeGetCustomer(username: String): DemobankCustomerEntity? { - return transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.username eq username - }.firstOrNull() - } -} - -/** - * Get person name from a customer's username, or throw - * exception if not found. - */ -fun getPersonNameFromCustomer(customerUsername: String): String { - return when (customerUsername) { - "admin" -> "Admin" - else -> transaction { - val ownerCustomer = DemobankCustomerEntity.find( - DemobankCustomersTable.username eq customerUsername - ).firstOrNull() ?: run { - logger.error("Customer '${customerUsername}' not found, couldn't get their name.") - throw SandboxError( - HttpStatusCode.InternalServerError, - "'$customerUsername' not a customer." - ) - - } - ownerCustomer.name ?: "Never given." - } - } -} - -fun getDefaultDemobank(): DemobankConfigEntity { - return transaction { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq "default" - }.firstOrNull() - } ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Default demobank is missing." - ) -} - -fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { - val uuid = parseUuid(opId) - return transaction { - TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq uuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Withdrawal operation $opId not found." - ) - } -} - -fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity { - val paytoParse = parsePayto(paytoUri) - return getBankAccountFromIban(paytoParse.iban) -} - -fun getBankAccountFromIban(iban: String): BankAccountEntity { - return transaction { - BankAccountEntity.find(BankAccountsTable.iban eq iban).firstOrNull() - } ?: throw SandboxError( - HttpStatusCode.NotFound, - "Did not find a bank account for $iban" - ) -} - -/** - * The argument 'withBankFault' represents the case where - * _the bank_ must ensure that a resource (in this case a bank - * account) exists. For example, every 'customer' should have - * a 'bank account', and if a customer is found without a bank - * account, then the bank broke such condition. - */ -fun getBankAccountFromLabel( - label: String, - demobank: String = "default", - withBankFault: Boolean = false -): BankAccountEntity { - val maybeDemobank = getDemobank(demobank) - if (maybeDemobank == null) { - logger.error("Demobank '$demobank' not found") - throw SandboxError( - HttpStatusCode.NotFound, - "Demobank '$demobank' not found" - ) - } - return getBankAccountFromLabel( - label, - maybeDemobank, - withBankFault - ) -} - -// Get bank account DAO, given its name and demobank. -fun getBankAccountFromLabel( - label: String, - demobank: DemobankConfigEntity, - withBankFault: Boolean = false // documented along the other same-named function. -): BankAccountEntity { - val maybeBankAccount = transaction { - BankAccountEntity.find( - BankAccountsTable.label eq label and ( - BankAccountsTable.demoBank eq demobank.id - ) - ).firstOrNull() - } - if (maybeBankAccount == null && withBankFault) - throw internalServerError( - "Bank account $label was not found, but it should." - ) - if (maybeBankAccount == null) - throw notFound( - "Bank account $label was not found." - ) - return maybeBankAccount -} - -fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity): BankAccountEntity { - return transaction { - subscriber.bankAccount ?: throw SandboxError( - HttpStatusCode.NotFound, - "Subscriber doesn't have any bank account" - ) - } -} - -fun BankAccountEntity.bonus(amount: String) { - wireTransfer( - "admin", - this.label, - this.demoBank.name, - "Sign-up bonus", - amount - ) -} - -fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity { - return ensureDemobank(call.expectUriComponent("demobankid")) -} - -fun ensureDemobank(name: String): DemobankConfigEntity { - return transaction { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq name - }.firstOrNull() ?: throw notFound("Demobank '$name' not found. Was it ever created?") - } -} - -fun getDemobank(name: String?): DemobankConfigEntity? { - return transaction { - if (name == null) { - DemobankConfigEntity.all().firstOrNull() - } else { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq name - }.firstOrNull() - } - } -} - -fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: String): EbicsSubscriberEntity { - return transaction { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.userId eq userID) and (EbicsSubscribersTable.partnerId eq partnerID) and - (EbicsSubscribersTable.hostId eq hostID) - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Ebics subscriber (${userID}, ${partnerID}, ${hostID}) not found" - ) - } -} - -/** - * Compress, encrypt, encode a EBICS payload. The payload - * is assumed to be a Zip archive with only one entry. - * Return the customer key (second element) along the data. - */ -fun prepareEbicsPayload( - payload: String, pub: RSAPublicKey -): Pair { - val zipSingleton = mutableListOf(payload.toByteArray()).zip() - val compressedResponse = DeflaterInputStream(zipSingleton.inputStream()).use { - it.readAllBytes() - } - val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub) - return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc) -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt deleted file mode 100644 index dac660da..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * 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 - * - */ - -package tech.libeufin.sandbox - -import tech.libeufin.util.PaymentInfo - -data class WithdrawalRequest( - /** - * Note: the currency is redundant, because at each point during - * the execution the Demobank should have a handle of the currency. - */ - val amount: String // $CURRENCY:X.Y -) -data class BalanceJson( - val amount: String, - val credit_debit_indicator: String -) -data class Demobank( - val currency: String, - val name: String, - val userDebtLimit: Int, - val bankDebtLimit: Int, - val allowRegistrations: Boolean -) -/** - * Used to show the list of Ebics hosts that exist - * in the system. - */ -data class EbicsHostsResponse( - val ebicsHosts: List -) - -data class EbicsHostCreateRequest( - val hostID: String, - val ebicsVersion: String -) - -/** - * List type that show all the payments existing in the system. - */ -data class AccountTransactions( - val payments: MutableList = mutableListOf() -) - -/** - * Used to create AND show one Ebics subscriber. - */ -data class EbicsSubscriberInfo( - val hostID: String, - val partnerID: String, - val userID: String, - val systemID: String? = null, - val demobankAccountLabel: String -) - -data class AdminGetSubscribers( - var subscribers: MutableList = mutableListOf() -) - -/** - * The following definition is obsolete because it - * doesn't allow to specify a demobank that will host - * the Ebics subscriber. */ -data class EbicsSubscriberObsoleteApi( - val hostID: String, - val partnerID: String, - val userID: String, - val systemID: String? = null -) - -/** - * Allows the admin to associate a new bank account - * to a EBICS subscriber. - */ -data class EbicsBankAccountRequest( - val subscriber: EbicsSubscriberObsoleteApi, - val iban: String, - val bic: String, - val name: String, - /** - * This value labels the bank account to be created - * AND its owner. The 'owner' is a bank's customer - * whose username equals this label AND has the rights - * over such bank accounts. - */ - val label: String -) - -data class CustomerRegistration( - val username: String, - val password: String, - val isPublic: Boolean = false, - // When missing, it's autogenerated. - val iban: String?, - // When missing, stays null in the DB. - val name: String? -) - -// Could be used as a general bank account info container. -data class PublicAccountInfo( - val balance: String, - val iban: String, - // Name / Label of the bank account _and_ of the - // Sandbox username that owns it. - val accountLabel: String - // more ..? -) - -data class CamtParams( - // name/label of the bank account to query. - val bankaccount: String, - val type: Int, - // need range parameter -) - -data class TalerWithdrawalStatus( - val selection_done: Boolean, - val transfer_done: Boolean, - val amount: String, - val wire_types: List = listOf("iban"), - val suggested_exchange: String? = null, - val sender_wire: String? = null, - val aborted: Boolean, - // Not needed with CLI wallets. - val confirm_transfer_url: String? -) - -data class TalerWithdrawalSelection( - val reserve_pub: String, - val selected_exchange: String? -) - -data class SandboxConfig( - val currency: String, - val version: String, - val name: String -) \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt deleted file mode 100644 index bcd11a49..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt +++ /dev/null @@ -1,1711 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * 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 - * - */ - -package tech.libeufin.sandbox - -import UtilError -import com.fasterxml.jackson.core.util.DefaultIndenter -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.KotlinFeature -import com.fasterxml.jackson.module.kotlin.KotlinModule -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.context -import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.output.CliktHelpFormatter -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.int -import execThrowableOrTerminate -import io.ktor.server.application.* -import io.ktor.http.* -import io.ktor.serialization.jackson.* -import io.ktor.server.plugins.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.plugins.statuspages.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.util.* -import io.ktor.server.plugins.callloging.* -import io.ktor.server.plugins.cors.routing.* -import io.ktor.util.date.* -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.slf4j.event.Level -import org.w3c.dom.Document -import startServer -import tech.libeufin.util.* -import java.math.BigDecimal -import java.net.URL -import java.security.interfaces.RSAPublicKey -import javax.xml.bind.JAXBContext -import kotlin.system.exitProcess - -val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox") -const val PROTOCOL_VERSION_UNIFIED = "0:0:0" // Every protocol is still using the same version. -const val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION" -private val adminPassword: String? = System.getenv("LIBEUFIN_SANDBOX_ADMIN_PASSWORD") -var WITH_AUTH = true // Needed by helpers too, hence not making it private. - -// Internal error type. -data class SandboxError( - val statusCode: HttpStatusCode, - val reason: String, - val errorCode: LibeufinErrorCode? = null -) : Exception(reason) - -// HTTP response error type. -data class SandboxErrorJson(val error: SandboxErrorDetailJson) -data class SandboxErrorDetailJson(val type: String, val description: String) - -class DefaultExchange : CliktCommand("Set default Taler exchange for a demobank.") { - init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } - private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of the default exchange") - private val exchangePayto by argument("EXCHANGE-PAYTO", "default exchange's payto-address") - private val demobank by option("--demobank", help = "Which demobank defaults to EXCHANGE").default("default") - - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbCreateTables(dbConnString) - transaction { - val maybeDemobank: DemobankConfigEntity? = DemobankConfigEntity.find { - DemobankConfigsTable.name eq demobank - }.firstOrNull() - if (maybeDemobank == null) { - System.err.println("Error, demobank $demobank not found.") - exitProcess(1) - } - val config = maybeDemobank.config - /** - * Iterating over the config object's field that hold the exchange - * base URL and Payto. The iteration is only used to retrieve the - * correct names of the DB column 'configKey', because this is named - * after such fields. - */ - listOf( - Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl), - Pair(config::suggestedExchangePayto, exchangePayto) - ).forEach { - val maybeConfigPair = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName eq demobank and( - DemobankConfigPairsTable.configKey eq it.first.name) - }.firstOrNull() - /** - * The DB doesn't contain any column to hold the exchange URL - * or Payto, fail. That should never happen, because the DB row - * are created _after_ the DemobankConfig object that _does_ contain - * such fields. - */ - if (maybeConfigPair == null) { - System.err.println("Config key '${it.first.name}' for demobank '$demobank' not found in DB.") - exitProcess(1) - } - maybeConfigPair.configValue = it.second - } - } - } - } -} - -class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into the database.") { - init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } - private val nameArgument by argument( - "NAME", help = "Name of this configuration. Currently, only 'default' is admitted." - ) - private val showOption by option( - "--show", - help = "Only show values, other options will be ignored." - ).flag("--no-show", default = false) - // FIXME: This really should not be a global option! - private val captchaUrlOption by option( - "--captcha-url", help = "Needed for browser wallets." - ).default("https://bank.demo.taler.net/") - private val currencyOption by option("--currency").default("EUR") - private val bankDebtLimitOption by option("--bank-debt-limit").int().default(1000000) - private val usersDebtLimitOption by option("--users-debt-limit").int().default(1000) - private val allowRegistrationsOption by option( - "--with-registrations", - help = "(defaults to allow registrations)" /* mentioning here as help message did not. */ - ).flag("--without-registrations", default = true) - private val withSignupBonusOption by option( - "--with-signup-bonus", - help = "Award new customers with 100 units of currency! (defaults to NO bonus)" - ).flag("--without-signup-bonus", default = false) - - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - if (nameArgument != "default") { - System.err.println("This version admits only the 'default' name") - exitProcess(1) - } - execThrowableOrTerminate { - dbCreateTables(dbConnString) - val maybeDemobank = transaction { getDemobank(nameArgument) } - if (showOption) { - if (maybeDemobank != null) { - printConfig(maybeDemobank) - } else { - println("Demobank: $nameArgument not found.") - System.exit(1) - } - return@execThrowableOrTerminate - } - if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) { - System.err.println("Debt numbers can't be negative.") - exitProcess(1) - } - /* - Warning if the CAPTCHA URL does not include the {wopid} placeholder. - Not a reason to fail because the bank may be run WITHOUT providing Taler. - */ - if (!hasWopidPlaceholder(captchaUrlOption)) - logger.warn("CAPTCHA URL doesn't have the WOPID placeholder." + - " Taler withdrawals decrease usability") - - // The user asks to _set_ values, regardless of overriding or creating. - val config = DemobankConfig( - currency = currencyOption, - bankDebtLimit = bankDebtLimitOption, - usersDebtLimit = usersDebtLimitOption, - allowRegistrations = allowRegistrationsOption, - demobankName = nameArgument, - withSignupBonus = withSignupBonusOption, - captchaUrl = captchaUrlOption - ) - /** - * The demobank didn't exist. Now: - * 1, Store the config values in the database. - * 2, Store the demobank name in the database. - * 3, Create the admin bank account under this demobank. - */ - if (maybeDemobank == null) { - transaction { - insertConfigPairs(config) - val demoBank = DemobankConfigEntity.new { this.name = nameArgument } - BankAccountEntity.new { - iban = getIban() - label = "admin" - owner = "admin" // Not backed by an actual customer object. - // For now, the model assumes always one demobank - this.demoBank = demoBank - } - } - } - // Demobank exists: update its config values in the database. - else transaction { insertConfigPairs(config, override = true) } - } - } -} - -/** - * This command generates Camt53 statements - for all the bank accounts - - * every time it gets run. The statements are only stored into the database. - * The user should then query either via Ebics or via the JSON interface, - * in order to retrieve their statements. - */ -class Camt053Tick : CliktCommand( - "Make a new Camt.053 time tick; all the fresh transactions" + - " will be inserted in a new Camt.053 report" -) { - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { dbCreateTables(dbConnString) } - val newStatements = mutableMapOf>() - /** - * For each bank account, extract the latest statement and - * include all the later transactions in a new statement. - * Build empty statement, if the account does not have any - * transaction yet. - */ - transaction { - BankAccountEntity.all().forEach { accountIter -> - // Give this account a entry in the final output. - newStatements.putIfAbsent(accountIter.label, mutableListOf()) - val lastStatement = BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq accountIter.id.value - }.lastOrNull() - val lastStatementTime = lastStatement?.creationTime ?: 0L - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.date.greater(lastStatementTime) and( - BankAccountTransactionsTable.account eq accountIter.id.value - ) - }.forEach { - newStatements[accountIter.label]?.add( - getHistoryElementFromTransactionRow(it) - ) ?: run { - logger.error("Array operation failed while building statements for account: ${accountIter.label}") - System.err.println("Fatal array error while building the statement, please report.") - exitProcess(1) - } - } - /** - * Resorting the closing (CLBD) balance of the last statement; will - * become the PRCD balance of the _new_ one. - */ - val camtData = buildCamtString( - 53, - accountIter.iban, - newStatements[accountIter.label]!!, - currency = accountIter.demoBank.config.currency - ) - BankAccountStatementEntity.new { - statementId = camtData.messageId - creationTime = getSystemTimeNow().toInstant().epochSecond - xmlMessage = camtData.camtMessage - bankAccount = accountIter - } - } - BankAccountFreshTransactionsTable.deleteAll() - } - } -} - -class MakeTransaction : CliktCommand("Wire-transfer money between Sandbox bank accounts") { - init { - context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } - } - private val creditAccount by option(help = "Label of the bank account receiving the payment").required() - private val debitAccount by option(help = "Label of the bank account issuing the payment").required() - private val demobankArg by option("--demobank", help = "Which Demobank books this transaction").default("default") - private val amount by argument("AMOUNT", "Amount, in the CUR:X.Y format") - private val subjectArg by argument("SUBJECT", "Payment's subject") - - override fun run() { - /** - * Merely connecting here (and NOT creating any table) because this - * command should only be run after actual bank accounts exist in the - * system, meaning therefore that the database got already set up. - */ - execThrowableOrTerminate { - val pgConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - connectWithSchema(getJdbcConnectionFromPg(pgConnString)) - } - // Refuse to operate without a default demobank. - val demobank = getDemobank("default") - if (demobank == null) { - System.err.println("Sandbox cannot operate without a 'default' demobank.") - System.err.println("Please make one with the 'libeufin-sandbox config' command.") - exitProcess(1) - } - try { - wireTransfer(debitAccount, creditAccount, demobankArg, subjectArg, amount) - } catch (e: SandboxError) { - System.err.println(e.message) - exitProcess(1) - } catch (e: Exception) { - System.err.println(e.message) - exitProcess(1) - } - } -} - -class ResetTables : CliktCommand("Drop all the tables from the database") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbDropTables(dbConnString) - dbCreateTables(dbConnString) - } - } -} - -class Serve : CliktCommand("Run sandbox HTTP server") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - private val auth by option( - "--auth", - help = "Disable authentication." - ).flag("--no-auth", default = true) - private val localhostOnly by option( - "--localhost-only", - help = "Bind only to localhost. On all interfaces otherwise" - ).flag("--no-localhost-only", default = true) - private val ipv4Only by option( - "--ipv4-only", - help = "Bind only to ipv4" - ).flag(default = false) - private val logLevel by option( - help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" - ) - private val port by option().int().default(5000) - private val withUnixSocket by option( - help = "Bind the Sandbox to the Unix domain socket at PATH. Overrides" + - " --port, when both are given", metavar = "PATH" - ) - private val smsTan by option(help = "Command to send the TAN via SMS." + - " The command gets the TAN via STDIN and the phone number" + - " as its first parameter" - ) - private val emailTan by option(help = "Command to send the TAN via e-mail." + - " The command gets the TAN via STDIN and the e-mail address as its" + - " first parameter.") - override fun run() { - WITH_AUTH = auth - setLogLevel(logLevel) - if (WITH_AUTH && adminPassword == null) { - System.err.println( - "Error: auth is enabled, but env " + - "LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not." - + " (Option --no-auth exists for tests)" - ) - exitProcess(1) - } - execThrowableOrTerminate { - dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) - } - // Refuse to operate without a 'default' demobank. - val demobank = getDemobank("default") - if (demobank == null) { - System.err.println("Sandbox cannot operate without a 'default' demobank.") - System.err.println("Please make one with the 'libeufin-sandbox config' command.") - exitProcess(1) - } - if (withUnixSocket != null) { - startServer( - withUnixSocket!!, - app = sandboxApp - ) - exitProcess(0) - } - SMS_TAN_CMD = smsTan - EMAIL_TAN_CMD = emailTan - - logger.info("Starting Sandbox on port ${this.port}") - startServerWithIPv4Fallback( - options = StartServerOptions( - ipv4OnlyOpt = this.ipv4Only, - localhostOnlyOpt = this.localhostOnly, - portOpt = this.port - ), - app = sandboxApp - ) - } -} - -private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank { - return Demobank( - currency = fromDb.config.currency, - userDebtLimit = fromDb.config.usersDebtLimit, - bankDebtLimit = fromDb.config.bankDebtLimit, - allowRegistrations = fromDb.config.allowRegistrations, - name = fromDb.name - ) -} -fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?): EbicsSubscriberEntity? { - return if (systemID == null) { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq partnerID) and (EbicsSubscribersTable.userId eq userID) - } - } else { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq partnerID) and - (EbicsSubscribersTable.userId eq userID) and - (EbicsSubscribersTable.systemId eq systemID) - } - }.firstOrNull() -} - -data class SubscriberKeys( - val authenticationPublicKey: RSAPublicKey, - val encryptionPublicKey: RSAPublicKey, - val signaturePublicKey: RSAPublicKey -) - -data class EbicsHostPublicInfo( - val hostID: String, - val encryptionPublicKey: RSAPublicKey, - val authenticationPublicKey: RSAPublicKey -) - -data class BankAccountInfo( - val label: String, - val name: String, - val iban: String, - val bic: String, -) - -inline fun Document.toObject(): T { - val jc = JAXBContext.newInstance(T::class.java) - val m = jc.createUnmarshaller() - return m.unmarshal(this, T::class.java).value -} - -fun ensureNonNull(param: String?): String { - return param ?: throw SandboxError( - HttpStatusCode.BadRequest, "Bad ID given: $param" - ) -} - -class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnEmptyArgs = true) { - init { versionOption(getVersion()) } - override fun run() = Unit -} - -fun main(args: Array) { - SandboxCommand().subcommands( - Serve(), - ResetTables(), - Config(), - MakeTransaction(), - Camt053Tick(), - DefaultExchange() - ).main(args) -} - -fun setJsonHandler(ctx: ObjectMapper) { - ctx.enable(SerializationFeature.INDENT_OUTPUT) - ctx.setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { - indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) - indentObjectsWith(DefaultIndenter(" ", "\n")) - }) - ctx.registerModule( - KotlinModule.Builder() - .withReflectionCacheSize(512) - .configure(KotlinFeature.NullToEmptyCollection, false) - .configure(KotlinFeature.NullToEmptyMap, false) - .configure(KotlinFeature.NullIsSameAsDefault, enabled = true) - .configure(KotlinFeature.SingletonSupport, enabled = false) - .configure(KotlinFeature.StrictNullChecks, false) - .build() - ) -} - -private suspend fun getWithdrawal(call: ApplicationCall) { - val op = getWithdrawalOperation(call.expectUriComponent("withdrawal_id")) - if (!op.selectionDone && op.reservePub != null) throw internalServerError( - "Unselected withdrawal has a reserve public key", - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE - ) - call.respond(object { - val amount = op.amount - val aborted = op.aborted - val confirmation_done = op.confirmationDone - val selection_done = op.selectionDone - val selected_reserve_pub = op.reservePub - val selected_exchange_account = op.selectedExchangePayto - }) -} - -private suspend fun confirmWithdrawal(call: ApplicationCall) { - val withdrawalId = call.expectUriComponent("withdrawal_id") - logger.debug("Maybe confirming withdrawal: $withdrawalId") - transaction { - val wo = getWithdrawalOperation(withdrawalId) - if (wo.aborted) throw SandboxError( - HttpStatusCode.Conflict, - "Cannot confirm an aborted withdrawal." - ) - if (!wo.selectionDone) throw SandboxError( - HttpStatusCode.UnprocessableEntity, - "Cannot confirm a unselected withdrawal: " + - "specify exchange and reserve public key via Integration API first." - ) - /** - * The wallet chose not to select any exchange, use the default. - */ - val demobank = ensureDemobank(call) - if (wo.selectedExchangePayto == null) { - wo.selectedExchangePayto = demobank.config.suggestedExchangePayto - } - val exchangeBankAccount = getBankAccountFromPayto( - wo.selectedExchangePayto ?: throw internalServerError( - "Cannot withdraw without an exchange." - ) - ) - logger.debug("Withdrawal ${wo.wopid} confirmed? ${wo.confirmationDone}") - if (!wo.confirmationDone) { - wireTransfer( - debitAccount = wo.walletBankAccount.label, - creditAccount = exchangeBankAccount.label, - amount = wo.amount, - subject = wo.reservePub ?: throw internalServerError( - "Cannot transfer funds without reserve public key." - ), - // provide the currency. - demobank = ensureDemobank(call).name - ) - wo.confirmationDone = true - } - wo.confirmationDone - } - call.respond(object {}) -} - -private suspend fun abortWithdrawal(call: ApplicationCall) { - val withdrawalId = call.expectUriComponent("withdrawal_id") - val operation = getWithdrawalOperation(withdrawalId) - if (operation.confirmationDone) throw conflict("Cannot abort paid withdrawal.") - transaction { operation.aborted = true } - call.respond(object {}) -} - -val sandboxApp: Application.() -> Unit = { - install(CallLogging) { - this.level = Level.DEBUG - this.logger = tech.libeufin.sandbox.logger - this.format { call -> - "${call.response.status()}, ${call.request.httpMethod.value} ${call.request.path()}" - } - } - install(CORS) { - anyHost() - allowHeader(HttpHeaders.Authorization) - allowHeader(HttpHeaders.ContentType) - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Patch) - allowMethod(HttpMethod.Delete) - allowCredentials = true - } - install(IgnoreTrailingSlash) - install(ContentNegotiation) { - register(ContentType.Text.Xml, XMLEbicsConverter()) - /** - * Content type "text" must go to the XML parser - * because Nexus can't set explicitly the Content-Type - * (see https://github.com/ktorio/ktor/issues/1127) to - * "xml" and the request made gets somehow assigned the - * "text/plain" type: */ - register(ContentType.Text.Plain, XMLEbicsConverter()) - jackson(contentType = ContentType.Application.Json) { setJsonHandler(this) } - /** - * Make jackson the default parser. It runs also when - * the Content-Type request header is missing. */ - jackson(contentType = ContentType.Any) { setJsonHandler(this) } - } - install(StatusPages) { - // Bank's fault: it should check the operands. Respond 500 - exception { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.stackTraceToString()}") - call.respond( - HttpStatusCode.InternalServerError, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.message ?: "Bank's error: arithmetic exception." - ) - ) - ) - } - // Not necessarily the bank's fault. - exception { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") - call.respond( - cause.statusCode, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.reason - ) - ) - ) - } - // Not necessarily the bank's fault. - exception { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") - call.respond( - cause.statusCode, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "util-error", - description = cause.reason - ) - ) - ) - } - /** - * Happens when a request fails to parse. This branch triggers - * only when a JSON request fails. XML problems are caught within - * the /ebicsweb handler and always ultimately rethrown as "EbicsRequestError", - * hence they do not reach this branch. - */ - exception { call, wrapper -> - var rootCause = wrapper.cause - while (rootCause?.cause != null) rootCause = rootCause.cause - val errorMessage: String? = rootCause?.message ?: wrapper.message - if (errorMessage == null) { - logger.error("The bank didn't detect the cause of a bad request, fail.") - logger.error(wrapper.stackTraceToString()) - throw SandboxError( - HttpStatusCode.InternalServerError, - "Did not find bad request details." - ) - } - logger.error(errorMessage) - call.respond( - HttpStatusCode.BadRequest, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = errorMessage - ) - ) - ) - } - // Catch-all error, respond 500 because the bank didn't handle it. - exception { call, cause -> - logger.error("Unhandled exception while handling '${call.request.uri}'\n${cause.stackTraceToString()}") - call.respond( - HttpStatusCode.InternalServerError, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.message ?: "Bank's error: unhandled exception." - ) - ) - ) - } - exception { call, cause -> - logger.error("Handling EbicsRequestError: ${cause.message}") - respondEbicsTransfer(call, cause.errorText, cause.errorCode) - } - } - intercept(ApplicationCallPipeline.Setup) { - val ac: ApplicationCall = call - ac.attributes.put(WITH_AUTH_ATTRIBUTE_KEY, WITH_AUTH) - if (WITH_AUTH) { - if(adminPassword == null) { - throw internalServerError( - "Sandbox has no admin password defined." + - " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment, " + - "or launch with --no-auth." - - ) - } - ac.attributes.put(ADMIN_PASSWORD_ATTRIBUTE_KEY, adminPassword) - } - return@intercept - } - intercept(ApplicationCallPipeline.Fallback) { - if (this.call.response.status() == null) { - call.respondText( - "Not found (no route matched).\n", - io.ktor.http.ContentType.Text.Plain, - io.ktor.http.HttpStatusCode.NotFound - ) - return@intercept finish() - } - } - routing { - get("/") { - call.respondText( - "Hello, this is the Sandbox\n", - ContentType.Text.Plain - ) - } - // Respond with the last statement of the requesting account. - // Query details in the body. - post("/admin/payments/camt") { - val username = call.request.basicAuth() - val body = call.receive() - if (body.type != 53) throw SandboxError( - HttpStatusCode.NotFound, - "Only Camt.053 documents can be generated." - ) - if (!allowOwnerOrAdmin(username, body.bankaccount)) - throw unauthorized("User '${username}' has no rights over" + - " bank account '${body.bankaccount}'") - val camtMessage = transaction { - val bankaccount = getBankAccountFromLabel( - body.bankaccount, - getDefaultDemobank() - ) - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankaccount.id - }.lastOrNull()?.xmlMessage ?: throw SandboxError( - HttpStatusCode.NotFound, - "Could not find any statements; please wait next tick" - ) - } - call.respondText( - camtMessage, ContentType.Text.Xml, HttpStatusCode.OK - ) - return@post - } - - /** - * Create a new bank account, no EBICS relation. Okay - * to let a user, since having a particular username allocates - * already a bank account with such label. - */ - post("/admin/bank-accounts/{label}") { - val username = call.request.basicAuth() - val body = call.receive() - if (!allowOwnerOrAdmin(username, body.label)) - throw unauthorized("User '$username' has no rights over" + - " bank account '${body.label}'" - ) - if (body.label == "admin" || body.label == "bank") throw forbidden( - "Requested bank account label '${body.label}' not allowed." - ) - transaction { - val maybeBankAccount = BankAccountEntity.find { - BankAccountsTable.label eq body.label - }.firstOrNull() - if (maybeBankAccount != null) - throw conflict("Bank account '${body.label}' exist already") - // owner username == bank account label - val maybeCustomer = DemobankCustomerEntity.find { - DemobankCustomersTable.username eq body.label - }.firstOrNull() - if (maybeCustomer == null) - throw notFound("Customer '${body.label}' not found," + - " cannot own any bank account.") - BankAccountEntity.new { - iban = body.iban - bic = body.bic - label = body.label - owner = body.label - demoBank = getDefaultDemobank() - } - } - call.respond(object {}) - return@post - } - - // Information about one bank account. - get("/admin/bank-accounts/{label}") { - val username = call.request.basicAuth() - val label = call.expectUriComponent("label") - val ret = transaction { - val demobank = getDefaultDemobank() - val bankAccount = getBankAccountFromLabel(label, demobank) - if (!allowOwnerOrAdmin(username, label)) - throw unauthorized("'${username}' has no rights over '$label'") - val balance = getBalance(bankAccount) - object { - val balance = "${bankAccount.demoBank.config.currency}:${balance}" - val iban = bankAccount.iban - val bic = bankAccount.bic - val label = bankAccount.label - } - } - call.respond(ret) - return@get - } - - // Book one incoming payment for the requesting account. - // The debtor is not required to have a customer account at this Sandbox. - post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive() - val accountLabel = ensureNonNull(call.parameters["label"]) - val reqDebtorBic = body.debtorBic - if (reqDebtorBic != null && !validateBic(reqDebtorBic)) { - throw SandboxError( - HttpStatusCode.BadRequest, - "invalid BIC" - ) - } - val amount = parseAmount(body.amount) - transaction { - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel( - accountLabel, demobank - ) - val randId = getRandomString(16) - val customer = getCustomer(accountLabel) - BankAccountTransactionEntity.new { - creditorIban = account.iban - creditorBic = account.bic - creditorName = customer.name ?: "Name not given." - debtorIban = body.debtorIban - debtorBic = reqDebtorBic - debtorName = body.debtorName - subject = body.subject - this.amount = amount.amount - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = "sandbox-$randId" - this.account = account - direction = "CRDT" - this.demobank = demobank - this.currency = demobank.config.currency - } - } - call.respond(object {}) - } - // Associates a new bank account with an existing Ebics subscriber. - post("/admin/ebics/bank-accounts") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive() - val subscriber = getEbicsSubscriberFromDetails( - body.subscriber.userID, - body.subscriber.partnerID, - body.subscriber.hostID - ) - val res = insertNewAccount( - username = body.label, - /** - * This value makes only happy the account creator helper. - * Logic using this OBSOLETE HTTP handler would NOT expect - * to use this password anyway. The reason is that such obsolete - * tests access their banking data always through the EBICS - * subscriber, needing therefore no HTTP basic password to operate. - */ - password = "not-used", - iban = body.iban - ) - transaction { subscriber.bankAccount = res.bankAccount } - call.respond({}) - return@post - } - - // Information about all the default demobank's bank accounts - get("/admin/bank-accounts") { - call.request.basicAuth(onlyAdmin = true) - val accounts = mutableListOf() - transaction { - val demobank = getDefaultDemobank() - // Finds all the accounts of this demobank. - BankAccountEntity.find { BankAccountsTable.demoBank eq demobank.id }.forEach { - accounts.add( - BankAccountInfo( - label = it.label, - bic = it.bic, - iban = it.iban, - name = "Bank account owner's name" - ) - ) - } - } - call.respond(accounts) - } - - // Details of all the transactions of one bank account. - get("/admin/bank-accounts/{label}/transactions") { - val username = call.request.basicAuth() - val ret = AccountTransactions() - val accountLabel = ensureNonNull(call.parameters["label"]) - if (!allowOwnerOrAdmin(username, accountLabel)) - throw unauthorized("Requesting user '${username}'" + - " has no rights over bank account '${accountLabel}'" - ) - transaction { - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel(accountLabel, demobank) - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq account.id - }.forEach { - ret.payments.add( - PaymentInfo( - accountLabel = account.label, - creditorIban = it.creditorIban, - accountServicerReference = it.accountServicerReference, - paymentInformationId = it.pmtInfId, - debtorIban = it.debtorIban, - subject = it.subject, - date = GMTDate(it.date).toHttpDate(), - amount = it.amount, - creditorBic = it.creditorBic, - creditorName = it.creditorName, - debtorBic = it.debtorBic, - debtorName = it.debtorName, - currency = it.currency, - creditDebitIndicator = when (it.direction) { - "CRDT" -> "credit" - "DBIT" -> "debit" - else -> throw Error("invalid direction") - } - ) - ) - } - } - call.respond(ret) - } - /** - * Generate one incoming and one outgoing transactions for - * one bank account. Counterparts do not need to have an account - * at this Sandbox. - */ - post("/admin/bank-accounts/{label}/generate-transactions") { - call.request.basicAuth(onlyAdmin = true) - transaction { - val accountLabel = ensureNonNull(call.parameters["label"]) - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel(accountLabel, demobank) - val transactionReferenceCrdt = getRandomString(8) - val transactionReferenceDbit = getRandomString(8) - - run { - val amount = kotlin.random.Random.nextLong(5, 25) - BankAccountTransactionEntity.new { - creditorIban = account.iban - creditorBic = account.bic - creditorName = "Creditor Name" - debtorIban = "DE64500105178797276788" - debtorBic = "DEUTDEBB101" - debtorName = "Max Mustermann" - subject = "sample transaction $transactionReferenceCrdt" - this.amount = amount.toString() - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = transactionReferenceCrdt - this.account = account - direction = "CRDT" - this.demobank = demobank - currency = demobank.config.currency - } - } - - run { - val amount = kotlin.random.Random.nextLong(5, 25) - - BankAccountTransactionEntity.new { - debtorIban = account.iban - debtorBic = account.bic - debtorName = "Debitor Name" - creditorIban = "DE64500105178797276788" - creditorBic = "DEUTDEBB101" - creditorName = "Max Mustermann" - subject = "sample transaction $transactionReferenceDbit" - this.amount = amount.toString() - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = transactionReferenceDbit - this.account = account - direction = "DBIT" - this.demobank = demobank - currency = demobank.config.currency - } - } - } - call.respond(object {}) - } - - /** - * Create a new EBICS subscriber without associating - * a bank account to it. Currently every registered - * user is allowed to call this. - */ - post("/admin/ebics/subscribers") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive() - transaction { - // Check the host ID exists. - EbicsHostEntity.find { - EbicsHostsTable.hostID eq body.hostID - }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") - // Check it exists first. - val maybeSubscriber = EbicsSubscriberEntity.find { - EbicsSubscribersTable.userId eq body.userID and ( - EbicsSubscribersTable.partnerId eq body.partnerID - ) and (EbicsSubscribersTable.systemId eq body.systemID) and - (EbicsSubscribersTable.hostId eq body.hostID) - }.firstOrNull() - if (maybeSubscriber != null) throw conflict("EBICS subscriber exists already") - EbicsSubscriberEntity.new { - partnerId = body.partnerID - userId = body.userID - systemId = null - hostId = body.hostID - state = SubscriberState.NEW - nextOrderID = 1 - } - } - call.respondText( - "Subscriber created.", - ContentType.Text.Plain, HttpStatusCode.OK - ) - return@post - } - - // Shows details of all the EBICS subscribers of this Sandbox. - get("/admin/ebics/subscribers") { - call.request.basicAuth(onlyAdmin = true) - val ret = AdminGetSubscribers() - transaction { - EbicsSubscriberEntity.all().forEach { - ret.subscribers.add( - EbicsSubscriberInfo( - userID = it.userId, - partnerID = it.partnerId, - hostID = it.hostId, - demobankAccountLabel = it.bankAccount?.label ?: "not associated yet" - ) - ) - } - } - call.respond(ret) - return@get - } - - // Change keys used in the EBICS communications. - post("/admin/ebics/hosts/{hostID}/rotate-keys") { - call.request.basicAuth(onlyAdmin = true) - val hostID: String = call.parameters["hostID"] ?: throw SandboxError( - io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in URL" - ) - transaction { - val host = EbicsHostEntity.find { - EbicsHostsTable.hostID eq hostID - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Host $hostID not found" - ) - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - host.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) - host.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) - host.signaturePrivateKey = ExposedBlob(pairC.private.encoded) - } - call.respondText( - "Keys of '${hostID}' rotated.", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - - // Create a new EBICS host - post("/admin/ebics/hosts") { - call.request.basicAuth(onlyAdmin = true) - val req = call.receive() - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - transaction { - val maybeHost = EbicsHostEntity.find { - EbicsHostsTable.hostID eq req.hostID - }.firstOrNull() - if (maybeHost != null) { - logger.info("EBICS host '${req.hostID}' exists already, this request conflicts.") - throw conflict("EBICS host '${req.hostID}' exists already") - } - EbicsHostEntity.new { - this.ebicsVersion = req.ebicsVersion - this.hostId = req.hostID - this.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) - this.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) - this.signaturePrivateKey = ExposedBlob(pairC.private.encoded) - } - } - call.respondText( - "Host '${req.hostID}' created.", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - - // Show the names of all the Ebics hosts - get("/admin/ebics/hosts") { - call.request.basicAuth(onlyAdmin = true) - val ebicsHosts = transaction { - EbicsHostEntity.all().map { it.hostId } - } - call.respond(EbicsHostsResponse(ebicsHosts)) - } - // Process one EBICS request - post("/ebicsweb") { - try { call.ebicsweb() } - /** - * The catch blocks try to extract a EBICS error message from the - * exception type being handled. NOT logging under each catch block - * as ultimately the registered exception handler is expected to log. */ - catch (e: UtilError) { - throw EbicsProcessingError("Serving EBICS threw unmanaged UtilError: ${e.reason}") - } - catch (e: SandboxError) { - val errorInfo: String = e.message ?: e.stackTraceToString() - logger.info(errorInfo) - // Should translate to EBICS error code. - when (e.errorCode) { - LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE -> throw EbicsProcessingError("Invalid bank state.") - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE -> throw EbicsProcessingError("Inconsistent bank state.") - else -> throw EbicsProcessingError("Unknown Libeufin error code: ${e.errorCode}.") - } - } - catch (e: EbicsNoDownloadDataAvailable) { - respondEbicsTransfer(call, e.errorText, e.errorCode) - } - catch (e: EbicsRequestError) { - /** - * Preventing the last catch-all block from handling - * a known error type. Rethrowing here to let the top-level - * handler take action. - */ - throw e - } - catch (e: Exception) { - logger.error(e.stackTraceToString()) - throw EbicsProcessingError(e.message) - } - return@post - } - - /** - * Create a new demobank instance with a particular currency, - * debt limit and possibly other configuration - * (could also be a CLI command for now) - */ - post("/demobanks") { - throw NotImplementedError("Feature only available at the libeufin-sandbox CLI") - } - - get("/demobanks") { - expectAdmin(call.request.basicAuth()) - val ret = object { val demoBanks = mutableListOf() } - transaction { - DemobankConfigEntity.all().forEach { - ret.demoBanks.add(getJsonFromDemobankConfig(it)) - } - } - call.respond(ret) - return@get - } - - get("/demobanks/{demobankid}") { - val demobank = ensureDemobank(call) - expectAdmin(call.request.basicAuth()) - call.respond(getJsonFromDemobankConfig(demobank)) - return@get - } - - route("/demobanks/{demobankid}") { - // NOTE: TWG assumes that username == bank account label. - route("/taler-wire-gateway") { - post("/{exchangeUsername}/admin/add-incoming") { - val username = call.expectUriComponent("exchangeUsername") - val usernameAuth = call.request.basicAuth() - if (username != usernameAuth) - throw forbidden("Bank account name and username differ: $username vs $usernameAuth") - logger.debug("TWG add-incoming passed authentication") - val body = try { call.receive() } - catch (e: Exception) { - logger.error("/admin/add-incoming failed at parsing the request body") - throw SandboxError( - HttpStatusCode.BadRequest, - "Invalid request" - ) - } - val singletonTx = transaction { - val demobank = ensureDemobank(call) - val bankAccountCredit = getBankAccountFromLabel(username, demobank) - if (bankAccountCredit.owner != username) throw forbidden( - "User '$username' cannot access bank account with label: $username." - ) - val bankAccountDebit = getBankAccountFromPayto(body.debit_account) - logger.debug("TWG add-incoming about to wire transfer") - val ref = wireTransfer( - bankAccountDebit.label, - bankAccountCredit.label, - demobank.name, - body.reserve_pub, - body.amount - ) - /** - * The remaining part aims at returning an x-libeufin-bank-formatted - * message to Nexus, to let it ingest the (incoming side of the) payment - * information. The format choice makes it more practical for Nexus, - * because it handles this format already for the x-libeufin-bank connection - * type. - */ - val incomingTx = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq ref and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) // closes the 'and'. - }.firstOrNull() - if (incomingTx == null) - throw internalServerError("Just created transaction not found in DB. AcctSvcrRef: $ref") - val incomingHistoryElement = getHistoryElementFromTransactionRow(incomingTx) - logger.debug("TWG add-incoming has wire transferred, AcctSvcrRef: $ref") - incomingHistoryElement - } - val resp = object { - val transactions = listOf(singletonTx) - } - call.respond(resp) - return@post - } - } - // Talk to wallets. - route("/integration-api") { - get("/config") { - val demobank = ensureDemobank(call) - call.respond(SandboxConfig( - name = "taler-bank-integration", - version = PROTOCOL_VERSION_UNIFIED, - currency = demobank.config.currency - )) - return@get - } - post("/withdrawal-operation/{wopid}") { - val arg = ensureNonNull(call.parameters["wopid"]) - val withdrawalUuid = parseUuid(arg) - val body = call.receive() - val transferDone = transaction { - val wo = TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq withdrawalUuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Withdrawal operation $withdrawalUuid not found." - ) - if (wo.confirmationDone) { - return@transaction true - } - if (wo.selectionDone) { - if (body.reserve_pub != wo.reservePub) throw SandboxError( - HttpStatusCode.Conflict, - "Selecting a different reserve from the one already selected" - ) - if (body.selected_exchange != wo.selectedExchangePayto) throw SandboxError( - HttpStatusCode.Conflict, - "Selecting a different exchange from the one already selected" - ) - return@transaction false - } - // Flow here means never selected, hence must as well never be paid. - if (wo.confirmationDone) throw internalServerError( - "Withdrawal ${wo.wopid} knew NO exchange and reserve pub, " + - "but is marked as paid!" - ) - wo.reservePub = body.reserve_pub - wo.selectedExchangePayto = body.selected_exchange - wo.selectionDone = true - false - } - call.respond(object { - val transfer_done: Boolean = transferDone - }) - return@post - } - get("/withdrawal-operation/{wopid}") { - val arg = ensureNonNull(call.parameters["wopid"]) - val maybeWithdrawalUuid = parseUuid(arg) - val maybeWithdrawalOp = transaction { - TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Withdrawal operation: $arg not found" - ) - } - val demobank = ensureDemobank(call) - val captchaPage: String? = demobank.config.captchaUrl?.replace("{wopid}",arg) - if (captchaPage == null) - throw internalServerError("demobank ${demobank.name} lacks the CAPTCHA URL from the configuration.") - val ret = TalerWithdrawalStatus( - selection_done = maybeWithdrawalOp.selectionDone, - transfer_done = maybeWithdrawalOp.confirmationDone, - amount = maybeWithdrawalOp.amount, - suggested_exchange = demobank.config.suggestedExchangeBaseUrl, - aborted = maybeWithdrawalOp.aborted, - confirm_transfer_url = captchaPage - ) - call.respond(ret) - return@get - } - } - route("/circuit-api") { - circuitApi(this) - } - // Talk to Web UI. - route("/access-api") { - post("/accounts/{account_name}/transactions") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - // note: admin has no rights to create transactions on non-admin accounts. - val authGranted: Boolean = !WITH_AUTH - if (!authGranted && username != bankAccount.label) - throw unauthorized("Username '$username' has no rights over bank account ${bankAccount.label}") - val req = call.receive() - val payto = parsePayto(req.paytoUri) - val amount: String? = payto.amount ?: req.amount - if (amount == null) throw badRequest("Amount is missing") - /** - * The transaction block below lets the 'demoBank' field - * of 'bankAccount' be correctly accessed. */ - transaction { - wireTransfer( - debitAccount = bankAccount.label, - creditAccount = getBankAccountFromIban(payto.iban).label, - demobank = bankAccount.demoBank.name, - subject = payto.message ?: throw badRequest( - "'message' query parameter missing in Payto address" - ), - amount = amount, - pmtInfId = req.pmtInfId - ) - } - call.respond(object {}) - return@post - } - // Information about one withdrawal. - get("/accounts/{account_name}/withdrawals/{withdrawal_id}") { - getWithdrawal(call) - return@get - } - // account-less style: - get("/withdrawals/{withdrawal_id}") { - getWithdrawal(call) - return@get - } - // Create a new withdrawal operation. - post("/accounts/{account_name}/withdrawals") { - var username = call.request.basicAuth() - val demobank = ensureDemobank(call) - /** - * Check here if the user has the right over the claimed bank account. After - * this check, the withdrawal operation will be allowed only by providing its - * UID. */ - val maybeOwnedAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted = !WITH_AUTH // note: admin not allowed on non-admin accounts - if (!authGranted && maybeOwnedAccount.owner != username) - throw unauthorized("Customer '$username' has no rights over bank account '${maybeOwnedAccount.label}'") - val req = call.receive() - // Check for currency consistency - val amount = parseAmount(req.amount) - if (amount.currency != demobank.config.currency) - throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.config.currency}") - // Check funds are sufficient. - if ( - maybeDebit( - maybeOwnedAccount.label, - BigDecimal(amount.amount), - transaction { maybeOwnedAccount.demoBank.name } - )) { - logger.error("Account ${maybeOwnedAccount.label} would surpass debit threshold. Not withdrawing") - throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") - } - val wo: TalerWithdrawalEntity = transaction { - TalerWithdrawalEntity.new { - this.amount = req.amount - walletBankAccount = maybeOwnedAccount - } - } - val baseUrl = URL(call.request.getBaseUrl()) - val withdrawUri = url { - protocol = URLProtocol( - name = "taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""), - defaultPort = -1 - ) - host = "withdraw" - val pathSegments = mutableListOf( - /** - * encodes the hostname(+port) of the actual - * bank that will serve the withdrawal request. - */ - baseUrl.host.plus( - if (baseUrl.port != -1) - ":${baseUrl.port}" - else "" - ) - ) - /** - * Slashes can only be intermediate and single, - * any other combination results in badly formed URIs. - * The following loop ensure this for the current URI path. - * This might even come from X-Forwarded-Prefix. - */ - baseUrl.path.split("/").forEach { - if (it.isNotEmpty()) pathSegments.add(it) - } - pathSegments.add("demobanks/${demobank.name}/integration-api/${wo.wopid}") - this.appendPathSegments(pathSegments) - } - call.respond(object { - val withdrawal_id = wo.wopid.toString() - val taler_withdraw_uri = withdrawUri - }) - return@post - } - // Confirm a withdrawal: no basic auth, because the ID should be unguessable. - post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") { - confirmWithdrawal(call) - return@post - } - // account-less style: - post("/withdrawals/{withdrawal_id}/confirm") { - confirmWithdrawal(call) - return@post - } - // Aborting withdrawals: - post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") { - abortWithdrawal(call) - return@post - } - // account-less style: - post("/withdrawals/{withdrawal_id}/abort") { - abortWithdrawal(call) - return@post - } - // Bank account basic information. - get("/accounts/{account_name}") { - val username = call.request.basicAuth() - val accountAccessed = call.expectUriComponent("account_name") - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel(accountAccessed, demobank) - val authGranted = !WITH_AUTH || bankAccount.isPublic || username == "admin" - if (!authGranted && bankAccount.owner != username) - throw forbidden("Customer '$username' cannot access bank account '$accountAccessed'") - val balance = getBalance(bankAccount) - logger.debug("Balance of '$username': ${balance.toPlainString()}") - call.respond(object { - val balance = object { - val amount = "${demobank.config.currency}:${balance.abs().toPlainString()}" - val credit_debit_indicator = if (balance < BigDecimal.ZERO) "debit" else "credit" - } - val paytoUri = buildIbanPaytoUri( - iban = bankAccount.iban, - bic = bankAccount.bic, - // username 'null' should only happen when auth is disabled. - receiverName = getPersonNameFromCustomer(bankAccount.owner) - ) - val iban = bankAccount.iban - // The Elvis operator helps the --no-auth case, - // where username would be empty - val debitThreshold = getMaxDebitForUser( - username = username ?: "admin", - demobankName = demobank.name - ).toString() - }) - return@get - } - get("/accounts/{account_name}/transactions/{tId}") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" - if (!authGranted && username != bankAccount.owner) - throw forbidden("Cannot access bank account ${bankAccount.label}") - val tId = call.parameters["tId"] ?: throw badRequest("URI didn't contain the transaction ID") - val tx: BankAccountTransactionEntity? = transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq tId - }.firstOrNull() - } - if (tx == null) throw notFound("Transaction $tId wasn't found") - call.respond(getHistoryElementFromTransactionRow(tx)) - return@get - } - get("/accounts/{account_name}/transactions") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" - if (!authGranted && bankAccount.owner != username) - throw forbidden("Cannot access bank account ${bankAccount.label}") - // Paging values. - val page: Int = expectInt(call.request.queryParameters["page"] ?: "1") - if (page < 1) throw badRequest("'page' param is less than 1") - val size: Int = expectInt(call.request.queryParameters["size"] ?: "5") - if (size < 1) throw badRequest("'size' param is less than 1") - // Time range filter values - val fromMs: Long = expectLong(call.request.queryParameters["from_ms"] ?: "0") - if (fromMs < 0) throw badRequest("'from_ms' param is less than 0") - val untilMs: Long = expectLong(call.request.queryParameters["until_ms"] ?: Long.MAX_VALUE.toString()) - if (untilMs < 0) throw badRequest("'until_ms' param is less than 0") - val longPollMs: Long? = call.maybeLong("long_poll_ms") - // LISTEN, if Postgres. - val listenHandle = if (isPostgres() && longPollMs != null) { - val channelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - call.expectUriComponent("account_name") - ) - val listenHandle = PostgresListenHandle(channelName) - // Can't LISTEN on the same DB TX that checks for data, as Exposed - // closes that connection and the notification getter would fail. - // Can't invoke the notification getter in the same DB TX either, - // as it would block the DB. - listenHandle.postgresListen() - listenHandle - } else null - val historyParams = HistoryParams( - pageNumber = page, - pageSize = size, - bankAccount = bankAccount, - fromMs = fromMs, - untilMs = untilMs - ) - var ret: List = transaction { - extractTxHistory(historyParams) - } - logger.debug("Is payment data empty? ${ret.isEmpty()}") - // Data was found already, UNLISTEN and respond. - if (listenHandle != null && ret.isNotEmpty()) { - logger.debug("No need to wait DB events, payment data found.") - listenHandle.postgresUnlisten() - call.respond(object {val transactions = ret}) - return@get - } - // No data was found, sleep until the timeout or getting woken up. - // Third condition only silences the compiler. - if (listenHandle != null && longPollMs != null) { - logger.debug("Waiting DB event for new payment data.") - val notificationArrived = listenHandle.waitOnIODispatchers(longPollMs) - // Only if the awaited event fired, query again the DB. - if (notificationArrived) - { - ret = transaction { - // Refreshing to update the index to the very last transaction. - historyParams.bankAccount.refresh() - extractTxHistory(historyParams) - } - } - } - call.respond(object {val transactions = ret}) - return@get - } - get("/public-accounts") { - val demobank = ensureDemobank(call) - val ret = object { - val publicAccounts = mutableListOf() - } - transaction { - BankAccountEntity.find { - BankAccountsTable.isPublic eq true and( - BankAccountsTable.demoBank eq demobank.id - ) - }.forEach { - val balanceIter = getBalance(it) - ret.publicAccounts.add( - PublicAccountInfo( - balance = "${demobank.config.currency}:$balanceIter", - iban = it.iban, - accountLabel = it.label - ) - ) - } - } - call.respond(ret) - return@get - } - delete("accounts/{account_name}") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val authGranted = !WITH_AUTH || username == "admin" - val bankAccountLabel = call.expectUriComponent("account_name") - /** - * This helper fails if the demobank that is mentioned in the URI - * is not hosting the account to be deleted. - */ - val bankAccount = getBankAccountFromLabel( - bankAccountLabel, - demobank - ) - if (!authGranted && username != bankAccount.owner) - throw unauthorized("User '$username' has no rights to delete bank account '$bankAccountLabel'") - transaction { - val customerAccount = getCustomer(bankAccount.owner) - bankAccount.delete() - customerAccount.delete() - } - call.respond(object {}) - return@delete - } - // Keeping the prefix "testing" not to break tests. - post("/testing/register") { - // Check demobank was created. - val demobank = ensureDemobank(call) - if (!demobank.config.allowRegistrations) { - throw SandboxError( - HttpStatusCode.UnprocessableEntity, - "The bank doesn't allow new registrations at the moment." - ) - } - val req = call.receive() - val newAccount = insertNewAccount( - req.username, - req.password, - name = req.name, - iban = req.iban, - demobank = demobank.name, - isPublic = req.isPublic - ) - val balance = getBalance(newAccount.bankAccount) - call.respond(object { - val balance = getBalanceForJson(balance, demobank.config.currency) - val paytoUri = buildIbanPaytoUri( - iban = newAccount.bankAccount.iban, - bic = newAccount.bankAccount.bic, - receiverName = getPersonNameFromCustomer(req.username) - ) - val iban = newAccount.bankAccount.iban - val debitThreshold = getMaxDebitForUser( - req.username, - demobank.name - ).toString() - }) - return@post - } - } - route("/ebics") { - /** - * Associate an existing bank account to one EBICS subscriber. - * If the subscriber is not found, it is created. - */ - post("/subscribers") { - // Only the admin can create Ebics subscribers. - val user = call.request.basicAuth() - if (WITH_AUTH && (user != "admin")) throw forbidden("Only the Administrator can create Ebics subscribers.") - val body = call.receive() - // Create or get the Ebics subscriber that is found. - transaction { - // Check that host ID exists - EbicsHostEntity.find { - EbicsHostsTable.hostID eq body.hostID - }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") - val subscriber: EbicsSubscriberEntity = EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq body.partnerID).and( - EbicsSubscribersTable.userId eq body.userID - ).and(EbicsSubscribersTable.hostId eq body.hostID) - }.firstOrNull() ?: EbicsSubscriberEntity.new { - partnerId = body.partnerID - userId = body.userID - systemId = null - hostId = body.hostID - state = SubscriberState.NEW - nextOrderID = 1 - } - val bankAccount = getBankAccountFromLabel( - body.demobankAccountLabel, - ensureDemobank(call) - ) - subscriber.bankAccount = bankAccount - } - call.respond(object {}) - return@post - } - } - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt deleted file mode 100644 index f76ad942..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt +++ /dev/null @@ -1,70 +0,0 @@ -package tech.libeufin.sandbox - -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.serialization.* -import io.ktor.util.reflect.* -import io.ktor.utils.io.* -import io.ktor.utils.io.charsets.* -import io.ktor.utils.io.jvm.javaio.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import tech.libeufin.util.XMLUtil - -class XMLEbicsConverter : ContentConverter { - override suspend fun deserialize( - charset: Charset, - typeInfo: TypeInfo, - content: ByteReadChannel - ): Any { - return withContext(Dispatchers.IO) { - try { - receiveEbicsXmlInternal(content.toInputStream().reader().readText()) - } catch (e: Exception) { - throw SandboxError( - HttpStatusCode.BadRequest, - "Document is invalid XML." - ) - } - } - } - - // The following annotation was suggested by Intellij. - @Deprecated( - "Please override and use serializeNullable instead", - replaceWith = ReplaceWith("serializeNullable(charset, typeInfo, contentType, value)"), - level = DeprecationLevel.WARNING - ) - override suspend fun serialize( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any - ): OutgoingContent? { - return super.serializeNullable(contentType, charset, typeInfo, value) - } - - override suspend fun serializeNullable( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any? - ): OutgoingContent? { - val conv = try { - XMLUtil.convertJaxbToString(value) - } catch (e: Exception) { - /** - * Not always an error: the content negotiation might have - * only checked if this handler could convert the response. - */ - return null - } - return OutputStreamContent({ - val out = this; - withContext(Dispatchers.IO) { - out.write(conv.toByteArray()) - }}, - contentType.withCharset(charset) - ) - } -} \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt deleted file mode 100644 index d82a0eb4..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt +++ /dev/null @@ -1,276 +0,0 @@ -package tech.libeufin.sandbox - -import io.ktor.http.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.math.BigDecimal - -/** - * Check whether the given bank account would surpass the - * debit threshold, in case the potential amount gets transferred. - * Returns true when the debit WOULD be surpassed. */ -fun maybeDebit( - accountLabel: String, - requestedAmount: BigDecimal, - demobankName: String = "default" -): Boolean { - val demobank = getDemobank(demobankName) ?: throw notFound( - "Demobank '${demobankName}' not found when trying to check the debit threshold" + - " for user $accountLabel" - ) - val balance = getBalance(accountLabel, demobankName) - val maxDebt = if (accountLabel == "admin") { - demobank.config.bankDebtLimit - } else demobank.config.usersDebtLimit - val balanceCheck = balance - requestedAmount - if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal.valueOf(maxDebt.toLong())) { - logger.warn("User '$accountLabel' would surpass the debit" + - " threshold of $maxDebt, given the requested amount of ${requestedAmount.toPlainString()}") - return true - } - return false -} - -fun getMaxDebitForUser( - username: String, - demobankName: String = "default" -): Int { - val bank = getDemobank(demobankName) ?: throw internalServerError( - "demobank $demobankName not found" - ) - if (username == "admin") return bank.config.bankDebtLimit - return bank.config.usersDebtLimit -} - -fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson { - return BalanceJson( - amount = "${currency}:${value.abs()}", - credit_debit_indicator = if (value < BigDecimal.ZERO) "debit" else "credit" - ) -} - -fun getBalance(bankAccount: BankAccountEntity): BigDecimal { - return BigDecimal(bankAccount.balance) -} - -/** - * This function balances _in bank account statements_. A statement - * witnesses the bank account after a given business time slot. Therefore - * _this_ type of balance is not guaranteed to hold the _actual_ and - * more up-to-date bank account. It'll be used when Sandbox will support - * the issuing of bank statement. - */ -fun getBalanceForStatement( - bankAccount: BankAccountEntity, - withPending: Boolean = true -): BigDecimal { - val lastStatement = transaction { - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull() - } - var lastBalance = if (lastStatement == null) { - BigDecimal.ZERO - } else { BigDecimal(lastStatement.balanceClbd) } - if (!withPending) return lastBalance - /** - * Caller asks to include the pending transactions in the - * balance. The block below gets the transactions happened - * later than the last statement and adds them to the balance - * that was calculated so far. - */ - transaction { - val pendingTransactions = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq bankAccount.id and ( - BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L)) - } - pendingTransactions.forEach { tx -> - when (tx.direction) { - "DBIT" -> lastBalance -= parseDecimal(tx.amount) - "CRDT" -> lastBalance += parseDecimal(tx.amount) - else -> { - logger.error("Transaction ${tx.id} is neither debit nor credit.") - throw SandboxError( - HttpStatusCode.InternalServerError, - "Error in transactions state." - ) - } - } - } - } - return lastBalance -} - -// Gets the balance of 'accountLabel', which is hosted at 'demobankName'. -fun getBalance(accountLabel: String, - demobankName: String = "default" -): BigDecimal { - val demobank = getDemobank(demobankName) ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Demobank '$demobankName' not found" - ) - - /** - * Setting withBankFault to true for the following reason: - * when asking for a balance, the bank should have made sure - * that the user has a bank account (together with a customer profile). - * If that's not the case, it's bank's fault, since it didn't check - * earlier. - */ - val account = getBankAccountFromLabel( - accountLabel, - demobank, - withBankFault = true - ) - return getBalance(account) -} - -/** - * 'debitAccount' and 'creditAccount' are customer usernames - * and ALSO labels of the bank accounts owned by them. They are - * used to both resort a bank account and the legal name owning - * the bank accounts. - */ -fun wireTransfer( - debitAccount: String, - creditAccount: String, - demobank: String = "default", - subject: String, - amount: String, // $currency:x.y - pmtInfId: String? = null, - endToEndId: String? = null -): String { - logger.debug("Maybe wire transfer (endToEndId: $endToEndId): $debitAccount -> $creditAccount, $subject, $amount") - return transaction { - val demobankDb = ensureDemobank(demobank) - val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb) - val creditAccountDb = getBankAccountFromLabel(creditAccount, demobankDb) - val parsedAmount = parseAmount(amount) - // Potential amount to transfer. - val amountAsNumber = BigDecimal(parsedAmount.amount) - if (amountAsNumber == BigDecimal.ZERO) - throw badRequest("Wire transfers of zero not possible.") - if (parsedAmount.currency != demobankDb.config.currency) - throw badRequest( - "Won't wire transfer with currency: ${parsedAmount.currency}." + - " Only ${demobankDb.config.currency} allowed." - ) - // Check funds are sufficient. - if ( - maybeDebit( - debitAccountDb.label, - amountAsNumber, - demobankDb.name - )) { - logger.error("Account ${debitAccountDb.label} would surpass debit threshold. Rollback wire transfer") - throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") - } - val timeStamp = getNowMillis() - val transactionRef = getRandomString(8) - BankAccountTransactionEntity.new { - creditorIban = creditAccountDb.iban - creditorBic = creditAccountDb.bic - this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) - debtorIban = debitAccountDb.iban - debtorBic = debitAccountDb.bic - debtorName = getPersonNameFromCustomer(debitAccountDb.owner) - this.subject = subject - this.amount = parsedAmount.amount - this.currency = demobankDb.config.currency - date = timeStamp - accountServicerReference = transactionRef - account = creditAccountDb - direction = "CRDT" - this.demobank = demobankDb - this.pmtInfId = pmtInfId - } - BankAccountTransactionEntity.new { - creditorIban = creditAccountDb.iban - creditorBic = creditAccountDb.bic - this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) - debtorIban = debitAccountDb.iban - debtorBic = debitAccountDb.bic - debtorName = getPersonNameFromCustomer(debitAccountDb.owner) - this.subject = subject - this.amount = parsedAmount.amount - this.currency = demobankDb.config.currency - date = timeStamp - accountServicerReference = transactionRef - account = debitAccountDb - direction = "DBIT" - this.demobank = demobankDb - this.pmtInfId = pmtInfId - this.endToEndId = endToEndId - } - - // Adjusting the balances (acceptable debit conditions checked before). - // Debit: - val newDebitBalance = (BigDecimal(debitAccountDb.balance) - amountAsNumber).roundToTwoDigits() - debitAccountDb.balance = newDebitBalance.toPlainString() - // Credit: - val newCreditBalance = (BigDecimal(creditAccountDb.balance) + amountAsNumber).roundToTwoDigits() - creditAccountDb.balance = newCreditBalance.toPlainString() - - // Signaling this wire transfer's event. - if (this.isPostgres()) { - val creditChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - creditAccountDb.label - ) - this.postgresNotify(creditChannel, "CRDT") - val debitChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - debitAccountDb.label - ) - this.postgresNotify(debitChannel, "DBIT") - } - transactionRef - } -} - -/** - * Helper that constructs a transactions history page - * according to the URI parameters passed to Access API's - * GET /transactions. - */ -data class HistoryParams( - val pageNumber: Int, - val pageSize: Int, - val fromMs: Long, - val untilMs: Long, - val bankAccount: BankAccountEntity -) - -fun extractTxHistory(params: HistoryParams): List { - val ret = mutableListOf() - - /** - * Helper that gets transactions earlier than the 'firstElementId' - * transaction AND that match the URI parameters. - */ - fun getPage(firstElementId: Long): Iterable { - return BankAccountTransactionEntity.find { - (BankAccountTransactionsTable.id lessEq firstElementId) and - (BankAccountTransactionsTable.account eq params.bankAccount.id) and - (BankAccountTransactionsTable.date.between(params.fromMs, params.untilMs)) - }.sortedByDescending { it.id.value }.take(params.pageSize) - } - // Gets a pointer to the last transaction of this bank account. - val lastTransaction: BankAccountTransactionEntity? = params.bankAccount.lastTransaction - if (lastTransaction == null) return ret - var nextPageIdUpperLimit: Long = lastTransaction.id.value - - // This loop fetches (and discards) pages until the desired one is found. - for (i in 1..(params.pageNumber)) { - val pageBuf = getPage(nextPageIdUpperLimit) - logger.debug("pageBuf #$i follows. Request wants #${params.pageNumber}:") - pageBuf.forEach { logger.debug("ID: ${it.id}, subject: ${it.subject}, amount: ${it.currency}:${it.amount}") } - if (pageBuf.none()) return ret - nextPageIdUpperLimit = pageBuf.last().id.value - 1 - if (i == params.pageNumber) pageBuf.forEach { - ret.add(getHistoryElementFromTransactionRow(it)) - } - } - return ret -} \ No newline at end of file -- cgit v1.2.3