/* * 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.core.JsonParseException import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import io.ktor.application.* import io.ktor.http.HttpStatusCode import io.ktor.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.math.BigDecimal /** * 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 ) /** * Return: * - null if the authentication is disabled (during tests, for example). * This facilitates tests because allows requests to lack entirely a * Authorization header. * - the name 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(): 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 } 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): RawPayment { return RawPayment( subject = dbRow.subject, creditorIban = dbRow.creditorIban, creditorBic = dbRow.creditorBic, creditorName = dbRow.creditorName, debtorIban = dbRow.debtorIban, debtorBic = dbRow.debtorBic, debtorName = dbRow.debtorName, date = importDateFromMillis(dbRow.date).toDashedDate(), amount = dbRow.amount, currency = dbRow.currency, // The line below produces a value too long (>35 chars), // and dbRow makes the document invalid! // uid = "${dbRow.pmtInfId}-${it.msgId}" uid = dbRow.accountServicerReference, direction = dbRow.direction, pmtInfId = dbRow.pmtInfId ) } fun getHistoryElementFromTransactionRow( dbRow: BankAccountFreshTransactionEntity ): RawPayment { return getHistoryElementFromTransactionRow(dbRow.transactionRef) } // Need to be called within a transaction {} block. fun getCustomer(username: String): DemobankCustomerEntity { return DemobankCustomerEntity.find { DemobankCustomersTable.username eq username }.firstOrNull() ?: throw notFound("Customer '${username}' not found") } /** * Get person name from a customer's username. */ fun getPersonNameFromCustomer(ownerUsername: String?): String { if (ownerUsername == null) { return "Name unknown" } return when (ownerUsername) { "admin" -> "admin" // Could be changed to Admin, or some different value. "bank" -> "The Bank" else -> transaction { val ownerCustomer = DemobankCustomerEntity.find( DemobankCustomersTable.username eq ownerUsername ).firstOrNull() ?: throw internalServerError( "Person name of '$ownerUsername' not found" ) ownerCustomer.name ?: "Name unknown" } } } fun getFirstDemobank(): DemobankConfigEntity { return transaction { DemobankConfigEntity.all().firstOrNull() ?: throw SandboxError( HttpStatusCode.InternalServerError, "Cannot find one demobank, please create one!" ) } } fun getDefaultDemobank(): DemobankConfigEntity { return transaction { DemobankConfigEntity.find { DemobankConfigsTable.name eq "default" }.firstOrNull() } ?: throw SandboxError( HttpStatusCode.InternalServerError, "Default demobank is missing." ) } fun wireTransfer( debitAccount: String, creditAccount: String, demobank: String, subject: String, amount: String // $currency:x.y ): String { val args: Triple = transaction { val debitAccountDb = BankAccountEntity.find { BankAccountsTable.label eq debitAccount }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Debit account '$debitAccount' not found" ) val creditAccountDb = BankAccountEntity.find { BankAccountsTable.label eq creditAccount }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Credit account '$creditAccount' not found" ) val demoBank = DemobankConfigEntity.find { DemobankConfigsTable.name eq demobank }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Demobank '$demobank' not found" ) Triple(debitAccountDb, creditAccountDb, demoBank) } /** * Only validating the amount. Actual check on the * currency will be done by the callee below. */ val amountObj = parseAmount(amount) return wireTransfer( debitAccount = args.first, creditAccount = args.second, demobank = args.third, subject = subject, amount = amountObj.amount.toPlainString() ) } /** * Book a CRDT and a DBIT transaction and return the unique reference thereof. * * At the moment there is redundancy because all the creditor / debtor details * are contained (directly or indirectly) already in the BankAccount parameters. * * This is kept both not to break the existing tests and to allow future versions * where one party of the transaction is not a customer of the running Sandbox. */ fun wireTransfer( debitAccount: BankAccountEntity, creditAccount: BankAccountEntity, demobank: DemobankConfigEntity, subject: String, amount: String, ): String { // sanity check on the amount, no currency allowed here. val checkAmount = parseDecimal(amount) if (checkAmount == BigDecimal.ZERO) throw badRequest("Wire transfers of zero not possible.") val timeStamp = getUTCnow().toInstant().toEpochMilli() val transactionRef = getRandomString(8) transaction { BankAccountTransactionEntity.new { creditorIban = creditAccount.iban creditorBic = creditAccount.bic this.creditorName = getPersonNameFromCustomer(creditAccount.owner) debtorIban = debitAccount.iban debtorBic = debitAccount.bic debtorName = getPersonNameFromCustomer(debitAccount.owner) this.subject = subject this.amount = amount this.currency = demobank.currency date = timeStamp accountServicerReference = transactionRef account = creditAccount direction = "CRDT" this.demobank = demobank } BankAccountTransactionEntity.new { creditorIban = creditAccount.iban creditorBic = creditAccount.bic this.creditorName = getPersonNameFromCustomer(creditAccount.owner) debtorIban = debitAccount.iban debtorBic = debitAccount.bic debtorName = getPersonNameFromCustomer(debitAccount.owner) this.subject = subject this.amount = amount this.currency = demobank.currency date = timeStamp accountServicerReference = transactionRef account = debitAccount direction = "DBIT" this.demobank = demobank } } return transactionRef } fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { return transaction { TalerWithdrawalEntity.find { TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(opId) }.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" ) } fun getBankAccountFromLabel(label: String, demobankName: String): BankAccountEntity { return transaction { val demobank: DemobankConfigEntity = DemobankConfigEntity.find { DemobankConfigsTable.name eq demobankName }.firstOrNull() ?: throw notFound("Demobank ${demobankName} not found") getBankAccountFromLabel(label, demobank) } } fun getBankAccountFromLabel(label: String, demobank: DemobankConfigEntity): BankAccountEntity { return transaction { BankAccountEntity.find( BankAccountsTable.label eq label and (BankAccountsTable.demoBank eq demobank.id) ).firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Did not find a bank account for label ${label}" ) } } 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( "bank", this.label, this.demoBank.name, "Sign-up bonus", amount ) } fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity { return ensureDemobank(call.getUriComponent("demobankid")) } private fun ensureDemobank(name: String): DemobankConfigEntity { return transaction { DemobankConfigEntity.find { DemobankConfigsTable.name eq name }.firstOrNull() ?: throw internalServerError("Demobank '$name' never 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 not found" ) } } /** * This helper tries to: * 1. Authenticate the client. * 2. Extract the bank account's label from the request's path * 3. Return the bank account DB object if the client has access to it. */ fun getBankAccountWithAuth(call: ApplicationCall): BankAccountEntity { val username = call.request.basicAuth() val accountAccessed = call.getUriComponent("account_name") val demobank = ensureDemobank(call) val bankAccount = transaction { val res = BankAccountEntity.find { (BankAccountsTable.label eq accountAccessed).and( BankAccountsTable.demoBank eq demobank.id ) }.firstOrNull() res } ?: throw notFound("Account '$accountAccessed' not found") // Check rights. if ( WITH_AUTH && (bankAccount.owner != username && username != "admin") ) throw forbidden( "Customer '$username' cannot access bank account '$accountAccessed'" ) return bankAccount }