libeufin

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

commit fb40741be92b179dba8fc416f993c59f1a6850aa
parent 1ce3c4d4251015283d61654be5196c1a9734ca40
Author: Florian Dold <florian@dold.me>
Date:   Sun, 24 Sep 2023 14:18:56 +0200

refactoring, adapt to core bank API withdrawal change

Diffstat:
Abank/src/main/kotlin/tech/libeufin/bank/Authentication.kt | 40++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 602+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 404++++++++++++++++++++++++++++++++++++-------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 34+---------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 9++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 290++++++++++++++++++++++++++++++++-----------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/types.kt | 573-------------------------------------------------------------------------------
Mcontrib/libeufin-bank.sample.conf | 2+-
Mutil/src/main/kotlin/HTTP.kt | 11+++--------
9 files changed, 952 insertions(+), 1013 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Authentication.kt b/bank/src/main/kotlin/tech/libeufin/bank/Authentication.kt @@ -0,0 +1,39 @@ +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import net.taler.common.errorcodes.TalerErrorCode +import tech.libeufin.util.getAuthorizationDetails +import tech.libeufin.util.getAuthorizationRawHeader + +/** + * This function tries to authenticate the call according + * to the scheme that is mentioned in the Authorization header. + * The allowed schemes are either 'HTTP basic auth' or 'bearer token'. + * + * requiredScope can be either "readonly" or "readwrite". + * + * Returns the authenticated customer, or null if they failed. + */ +fun ApplicationCall.authenticateBankRequest(db: Database, requiredScope: TokenScope): Customer? { + // Extracting the Authorization header. + val header = getAuthorizationRawHeader(this.request) ?: throw badRequest( + "Authorization header not found.", + TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + val authDetails = getAuthorizationDetails(header) ?: throw badRequest( + "Authorization is invalid.", + TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + return when (authDetails.scheme) { + "Basic" -> doBasicAuth(db, authDetails.content) + "Bearer" -> doTokenAuth(db, authDetails.content, requiredScope) + else -> throw LibeufinBankException( + httpStatus = HttpStatusCode.Unauthorized, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code, + hint = "Authorization method wrong or not supported." + ) + ) + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -0,0 +1,601 @@ +/* + * 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 + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.util.* + +/** + * Allowed lengths for fractional digits in amounts. + */ +enum class FracDigits(howMany: Int) { + TWO(2), + EIGHT(8) +} + + +/** + * Timestamp containing the number of seconds since epoch. + */ +@Serializable +data class Timestamp( + val t_s: Long // FIXME (?): not supporting "never" at the moment. +) + +/** + * HTTP response type of successful token refresh. + * access_token is the Crockford encoding of the 32 byte + * access token, whereas 'expiration' is the point in time + * when this token expires. + */ +@Serializable +data class TokenSuccessResponse( + val access_token: String, + val expiration: Timestamp +) + +/** + * Error object to respond to the client. The + * 'code' field takes values from the GANA gnu-taler-error-code + * specification. 'hint' is a human-readable description + * of the error. + */ +@Serializable +data class TalerError( + val code: Int, + val hint: String? = null +) + +/* Contains contact data to send TAN challges to the +* users, to let them complete cashout operations. */ +@Serializable +data class ChallengeContactData( + val email: String? = null, + val phone: String? = null +) + +// Type expected at POST /accounts +@Serializable +data class RegisterAccountRequest( + val username: String, + val password: String, + val name: String, + val is_public: Boolean = false, + val is_taler_exchange: Boolean = false, + val challenge_contact_data: ChallengeContactData? = null, + // External bank account where to send cashout amounts. + val cashout_payto_uri: String? = null, + // Bank account internal to Libeufin-Bank. + val internal_payto_uri: String? = null +) + +/** + * Internal representation of relative times. The + * "forever" case is represented with Long.MAX_VALUE. + */ +data class RelativeTime( + val d_us: Long +) + +/** + * Type expected at POST /accounts/{USERNAME}/token + * It complies with Taler's design document #49 + */ +@Serializable +data class TokenRequest( + val scope: TokenScope, + @Contextual + val duration: RelativeTime? = null, + val refreshable: Boolean = false +) + +/** + * Convenience type to throw errors along the bank activity + * and that is meant to be caught by Ktor and responded to the + * client. + */ +class LibeufinBankException( + // Status code that Ktor will set for the response. + val httpStatus: HttpStatusCode, + // Error detail object, after Taler API. + val talerError: TalerError +) : Exception(talerError.hint) + +/** + * Convenience type to hold customer data, typically after such + * data gets fetched from the database. It is also used to _insert_ + * customer data to the database. + */ +data class Customer( + val login: String, + val passwordHash: String, + val name: String, + /** + * Only non-null when this object is defined _by_ the + * database. + */ + val dbRowId: Long? = null, + val email: String? = null, + val phone: String? = null, + /** + * External bank account where customers send + * their cashout amounts. + */ + val cashoutPayto: String? = null, + /** + * Currency of the external bank account where + * customers send their cashout amounts. + */ + val cashoutCurrency: String? = null +) + +/** + * Represents a Taler amount. This type can be used both + * to hold database records and amounts coming from the parser. + * If maybeCurrency is null, then the constructor defaults it + * to be the "internal currency". Internal currency is the one + * with which Libeufin-Bank moves funds within itself, therefore + * not to be mistaken with the cashout currency, which is the one + * that gets credited to Libeufin-Bank users to their cashout_payto_uri. + * + * maybeCurrency is typically null when the TalerAmount object gets + * defined by the Database class. + */ +class TalerAmount( + val value: Long, + val frac: Int, + val currency: String +) { + override fun equals(other: Any?): Boolean { + return other is TalerAmount && + other.value == this.value && + other.frac == this.frac && + other.currency == this.currency + } + + override fun toString(): String { + return "$currency:$value.$frac" + } +} + +/** + * Convenience type to get and set bank account information + * from/to the database. + */ +data class BankAccount( + val internalPaytoUri: String, + // Database row ID of the customer that owns this bank account. + val owningCustomerId: Long, + val bankAccountId: Long? = null, // null at INSERT. + val isPublic: Boolean = false, + val isTalerExchange: Boolean = false, + /** + * Because bank accounts MAY be funded by an external currency, + * local bank accounts need to query Nexus, in order to find this + * out. This field is a pointer to the latest incoming payment that + * was contained in a Nexus history response. + * + * Typically, the 'admin' bank account uses this field, in order + * to initiate Taler withdrawals that depend on an external currency + * being wired by wallet owners. + */ + val lastNexusFetchRowId: Long = 0L, + val balance: TalerAmount? = null, // null when a new bank account gets created. + val hasDebt: Boolean, + val maxDebt: TalerAmount +) + +// Allowed values for bank transactions directions. +enum class TransactionDirection { + credit, + debit +} + +// Allowed values for cashout TAN channels. +enum class TanChannel { + sms, + email, + file // Writes cashout TANs to /tmp, for testing. +} + +// Scopes for authentication tokens. +enum class TokenScope { + readonly, + readwrite, + refreshable // Not spec'd as a scope! +} + +/** + * Convenience type to set/get authentication tokens to/from + * the database. + */ +data class BearerToken( + val content: ByteArray, + val scope: TokenScope, + val isRefreshable: Boolean = false, + val creationTime: Long, + val expirationTime: Long, + /** + * Serial ID of the database row that hosts the bank customer + * that is associated with this token. NOTE: if the token is + * refreshed by a client that doesn't have a user+password login + * in the system, the creator remains always the original bank + * customer that created the very first token. + */ + val bankCustomer: Long +) + +/** + * Convenience type to _communicate_ a bank transfer to the + * database procedure, NOT representing therefore any particular + * table. The procedure will then retrieve all the tables data + * from this type. + */ +data class BankInternalTransaction( + // Database row ID of the internal bank account sending the payment. + val creditorAccountId: Long, + // Database row ID of the internal bank account receiving the payment. + val debtorAccountId: Long, + val subject: String, + val amount: TalerAmount, + val transactionDate: Long, + val accountServicerReference: String = "not used", // ISO20022 + val endToEndId: String = "not used", // ISO20022 + val paymentInformationId: String = "not used" // ISO20022 +) + +/** + * Convenience type representing bank transactions as they + * are in the respective database table. Only used to _get_ + * the information from the database. + */ +data class BankAccountTransaction( + val creditorPaytoUri: String, + val creditorName: String, + val debtorPaytoUri: String, + val debtorName: String, + val subject: String, + val amount: TalerAmount, + val transactionDate: Long, // microseconds + /** + * Is the transaction debit, or credit for the + * bank account pointed by this object? + */ + val direction: TransactionDirection, + /** + * database row ID of the bank account that is + * impacted by the direction. For example, if the + * direction is debit, then this value points to the + * bank account of the payer. + */ + val bankAccountId: Long, + // Null if this type is used to _create_ one transaction. + val dbRowId: Long? = null, + // Following are ISO20022 specific. + val accountServicerReference: String, + val paymentInformationId: String, + val endToEndId: String, +) + +/** + * Represents a Taler withdrawal operation, as it is + * stored in the respective database table. + */ +data class TalerWithdrawalOperation( + val withdrawalUuid: UUID, + val amount: TalerAmount, + val selectionDone: Boolean = false, + val aborted: Boolean = false, + val confirmationDone: Boolean = false, + val reservePub: String?, + val selectedExchangePayto: String?, + val walletBankAccount: Long +) + +/** + * Represents a cashout operation, as it is stored + * in the respective database table. + */ +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 credit_payto_uri: String, + val cashoutCurrency: String +) + +// Type to return as GET /config response +@Serializable // Never used to parse JSON. +data class Config( + val name: String = "libeufin-bank", + val version: String = "0:0:0", + val have_cashout: Boolean = false, + // Following might probably get renamed: + val fiat_currency: String? = null +) + +@Serializable +data class Balance( + // FIXME: Should not be a string + val amount: String, + // FIXME: Should not be a string + val credit_debit_indicator: String, +) + +/** + * GET /accounts/$USERNAME response. + */ +@Serializable +data class AccountData( + val name: String, + val balance: Balance, + val payto_uri: String, + val debit_threshold: String, + val contact_data: ChallengeContactData? = null, + val cashout_payto_uri: String? = null, +) + +/** + * Response type of corebank API transaction initiation. + */ +@Serializable +data class BankAccountTransactionCreate( + val payto_uri: String, + val amount: String +) + +/* History element, either from GET /transactions/T_ID + or from GET /transactions */ +@Serializable +data class BankAccountTransactionInfo( + val creditor_payto_uri: String, + val debtor_payto_uri: String, + val amount: String, + val direction: TransactionDirection, + val subject: String, + val row_id: Long, // is T_ID + val date: Long +) + +// Response type for histories, namely GET /transactions +@Serializable +data class BankAccountTransactionsResponse( + val transactions: MutableList<BankAccountTransactionInfo> +) + +// Taler withdrawal request. +@Serializable +data class BankAccountCreateWithdrawalRequest( + val amount: String +) + +// Taler withdrawal response. +@Serializable +data class BankAccountCreateWithdrawalResponse( + val withdrawal_id: String, + val taler_withdraw_uri: String +) + +// Taler withdrawal details response +@Serializable +data class BankAccountGetWithdrawalResponse( + val amount: String, + val aborted: Boolean, + val confirmation_done: Boolean, + val selection_done: Boolean, + val selected_reserve_pub: String? = null, + val selected_exchange_account: String? = null +) + +typealias ResourceName = String + +/** + * Checks if the input Customer has the rights over ResourceName. + * FIXME: myAuth() gives null on failures, but this gives false. + * Should they return the same, for consistency? + */ +fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { + if (c.login == this) return true + if (c.login == "admin" && withAdmin) return true + return false +} + +/** + * Factors out the retrieval of the resource name from + * the URI. The resource looked for defaults to "USERNAME" + * as this is frequently mentioned resource along the endpoints. + * + * This helper is recommended because it returns a ResourceName + * type that then offers the ".canI()" helper to check if the user + * has the rights on the resource. + */ +fun ApplicationCall.getResourceName(param: String): ResourceName = + this.expectUriComponent(param) + +/** + * This type communicates the result of a database operation + * to confirm one withdrawal operation. + */ +enum class WithdrawalConfirmationResult { + SUCCESS, + OP_NOT_FOUND, + EXCHANGE_NOT_FOUND, + BALANCE_INSUFFICIENT, + + /** + * This state indicates that the withdrawal was already + * confirmed BUT Kotlin did not detect it and still invoked + * the SQL procedure to confirm the withdrawal. This is + * conflictual because only Kotlin is responsible to check + * for idempotency, and this state witnesses a failure in + * this regard. + */ + CONFLICT +} + +// GET /config response from the Taler Integration API. +@Serializable +data class TalerIntegrationConfigResponse( + val name: String = "taler-bank-integration", + val version: String = "0:0:0:", + val currency: String +) + +/** + * Withdrawal status as specified in the Taler Integration API. + */ +@Serializable +data class BankWithdrawalOperationStatus( + // Indicates whether the withdrawal was aborted. + val aborted: Boolean, + + /* Has the wallet selected parameters for the withdrawal operation + (exchange and reserve public key) and successfully sent it + to the bank? */ + val selection_done: Boolean, + + /* The transfer has been confirmed and registered by the bank. + Does not guarantee that the funds have arrived at the exchange + already. */ + val transfer_done: Boolean, + + /* Amount that will be withdrawn with this operation + (raw amount without fee considerations). */ + val amount: String, + + /* Bank account of the customer that is withdrawing, as a + ``payto`` URI. */ + val sender_wire: String? = null, + + // Suggestion for an exchange given by the bank. + val suggested_exchange: String? = null, + + /* URL that the user needs to navigate to in order to + complete some final confirmation (e.g. 2FA). + It may contain withdrawal operation id */ + val confirm_transfer_url: String? = null, + + // Wire transfer types supported by the bank. + val wire_types: MutableList<String> = mutableListOf("iban") +) + +/** + * Selection request on a Taler withdrawal. + */ +@Serializable +data class BankWithdrawalOperationPostRequest( + val reserve_pub: String, + val selected_exchange: String, +) + +/** + * Response to the wallet after it selects the exchange + * and the reserve pub. + */ +@Serializable +data class BankWithdrawalOperationPostResponse( + val transfer_done: Boolean, + val confirm_transfer_url: String? = null +) + +/** + * Request to an /admin/add-incoming request from + * the Taler Wire Gateway API. + */ +@Serializable +data class AddIncomingRequest( + val amount: String, + val reserve_pub: String, + val debit_account: String +) + +/** + * Response to /admin/add-incoming + */ +@Serializable +data class AddIncomingResponse( + val timestamp: Long, + val row_id: Long +) + +@Serializable +data class TWGConfigResponse( + val name: String = "taler-wire-gateway", + val version: String = "0:0:0:", + val currency: String +) + +/** + * Response of a TWG /history/incoming call. + */ +@Serializable +data class IncomingHistory( + val incoming_transactions: MutableList<IncomingReserveTransaction> = mutableListOf(), + val credit_account: String // Receiver's Payto URI. +) + +/** + * TWG's incoming payment record. + */ +@Serializable +data class IncomingReserveTransaction( + val type: String = "RESERVE", + val row_id: Long, // DB row ID of the payment. + val date: Long, // microseconds timestamp. + val amount: String, + val debit_account: String, // Payto of the sender. + val reserve_pub: String +) + +/** + * TWG's request to pay a merchant. + */ +@Serializable +data class TransferRequest( + val request_uid: String, + @Contextual + val amount: TalerAmount, + val exchange_base_url: String, + val wtid: String, + val credit_account: String +) + +/** + * TWG's response to merchant payouts + */ +@Serializable +data class TransferResponse( + val timestamp: Long, + val row_id: Long +) +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -24,15 +24,13 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { delete("/accounts/{USERNAME}/token") { throw internalServerError("Token deletion not implemented.") } - + post("/accounts/{USERNAME}/token") { - val customer = call.myAuth(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") + val customer = call.authenticateBankRequest(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") val endpointOwner = call.maybeUriComponent("USERNAME") - if (customer.login != endpointOwner) - throw forbidden( - "User has no rights on this enpoint", - TalerErrorCode.TALER_EC_END // FIXME: need generic forbidden - ) + if (customer.login != endpointOwner) throw forbidden( + "User has no rights on this enpoint", TalerErrorCode.TALER_EC_END // FIXME: need generic forbidden + ) val maybeAuthToken = call.getAuthToken() val req = call.receive<TokenRequest>() /** @@ -44,33 +42,29 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError( "Token used to auth not found in the database!" ) - if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) - throw forbidden( - "Cannot generate RW token from RO", - TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT - ) + if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) throw forbidden( + "Cannot generate RW token from RO", TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT + ) } val tokenBytes = ByteArray(32).apply { - java.util.Random().nextBytes(this) + Random().nextBytes(this) } val maxDurationTime: Long = ctx.maxAuthTokenDurationUs - if (req.duration != null && req.duration.d_us > maxDurationTime) - throw forbidden( - "Token duration bigger than bank's limit", - // FIXME: define new EC for this case. - TalerErrorCode.TALER_EC_END - ) - val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US + if (req.duration != null && req.duration.d_us > maxDurationTime) throw forbidden( + "Token duration bigger than bank's limit", + // FIXME: define new EC for this case. + TalerErrorCode.TALER_EC_END + ) + val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US val customerDbRow = customer.dbRowId ?: throw internalServerError( "Could not get customer '${customer.login}' database row ID" ) val expirationTimestampUs: Long = getNowUs() + tokenDurationUs - if (expirationTimestampUs < tokenDurationUs) - throw badRequest( - "Token duration caused arithmetic overflow", - // FIXME: need dedicate EC (?) - talerErrorCode = TalerErrorCode.TALER_EC_END - ) + if (expirationTimestampUs < tokenDurationUs) throw badRequest( + "Token duration caused arithmetic overflow", + // FIXME: need dedicate EC (?) + talerErrorCode = TalerErrorCode.TALER_EC_END + ) val token = BearerToken( bankCustomer = customerDbRow, content = tokenBytes, @@ -79,12 +73,10 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { scope = req.scope, isRefreshable = req.refreshable ) - if (!db.bearerTokenCreate(token)) - throw internalServerError("Failed at inserting new token in the database") + if (!db.bearerTokenCreate(token)) throw internalServerError("Failed at inserting new token in the database") call.respond( TokenSuccessResponse( - access_token = Base32Crockford.encode(tokenBytes), - expiration = Timestamp( + access_token = Base32Crockford.encode(tokenBytes), expiration = Timestamp( t_s = expirationTimestampUs / 1000000L ) ) @@ -95,27 +87,23 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { post("/accounts") { // check if only admin is allowed to create new accounts if (ctx.restrictRegistration) { - val customer: Customer? = call.myAuth(db, TokenScope.readwrite) - if (customer == null || customer.login != "admin") - throw LibeufinBankException( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.code, - hint = "Either 'admin' not authenticated or an ordinary user tried this operation." - ) + val customer: Customer? = call.authenticateBankRequest(db, TokenScope.readwrite) + if (customer == null || customer.login != "admin") throw LibeufinBankException( + httpStatus = HttpStatusCode.Unauthorized, talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.code, + hint = "Either 'admin' not authenticated or an ordinary user tried this operation." ) + ) } // auth passed, proceed with activity. val req = call.receive<RegisterAccountRequest>() // Prohibit reserved usernames: - if (req.username == "admin" || req.username == "bank") - throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( - code = GENERIC_UNDEFINED, // FIXME: this waits GANA. - hint = "Username '${req.username}' is reserved." - ) + if (req.username == "admin" || req.username == "bank") throw LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, talerError = TalerError( + code = GENERIC_UNDEFINED, // FIXME: this waits GANA. + hint = "Username '${req.username}' is reserved." ) + ) // Checking idempotency. val maybeCustomerExists = db.customerGetFromLogin(req.username) // Can be null if previous call crashed before completion. @@ -124,24 +112,19 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { db.bankAccountGetFromOwnerId(this.expectRowId()) } if (maybeCustomerExists != null && maybeHasBankAccount != null) { - tech.libeufin.bank.logger.debug("Registering username was found: ${maybeCustomerExists.login}") + logger.debug("Registering username was found: ${maybeCustomerExists.login}") // Checking _all_ the details are the same. val isIdentic = - maybeCustomerExists.name == req.name && - maybeCustomerExists.email == req.challenge_contact_data?.email && - maybeCustomerExists.phone == req.challenge_contact_data?.phone && - maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && - CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) && - maybeHasBankAccount.isPublic == req.is_public && - maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && - maybeHasBankAccount.internalPaytoUri == req.internal_payto_uri + maybeCustomerExists.name == req.name && maybeCustomerExists.email == req.challenge_contact_data?.email && maybeCustomerExists.phone == req.challenge_contact_data?.phone && maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && CryptoUtil.checkpw( + req.password, + maybeCustomerExists.passwordHash + ) && maybeHasBankAccount.isPublic == req.is_public && maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && maybeHasBankAccount.internalPaytoUri == req.internal_payto_uri if (isIdentic) { call.respond(HttpStatusCode.Created) return@post } throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( + httpStatus = HttpStatusCode.Conflict, talerError = TalerError( code = GENERIC_UNDEFINED, // GANA needs this. hint = "Idempotency check failed." ) @@ -159,8 +142,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { passwordHash = CryptoUtil.hashpw(req.password), ) val newCustomerRowId = db.customerCreate(newCustomer) - ?: throw internalServerError("New customer INSERT failed despite the previous checks") - /* Crashing here won't break data consistency between customers + ?: throw internalServerError("New customer INSERT failed despite the previous checks")/* Crashing here won't break data consistency between customers * and bank accounts, because of the idempotency. Client will * just have to retry. */ val maxDebt = ctx.defaultCustomerDebtLimit @@ -182,8 +164,8 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { */ val bonusAmount = if (ctx.registrationBonusEnabled) ctx.registrationBonus else null if (bonusAmount != null) { - val adminCustomer = db.customerGetFromLogin("admin") - ?: throw internalServerError("Admin customer not found") + val adminCustomer = + db.customerGetFromLogin("admin") ?: throw internalServerError("Admin customer not found") val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) ?: throw internalServerError("Admin bank account not found") val adminPaysBonus = BankInternalTransaction( @@ -193,111 +175,104 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { subject = "Registration bonus.", transactionDate = getNowUs() ) - when(db.bankTransactionCreate(adminPaysBonus)) { - Database.BankTransactionResult.NO_CREDITOR -> - throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") - Database.BankTransactionResult.NO_DEBTOR -> - throw internalServerError("Bonus impossible: admin not found.") - Database.BankTransactionResult.CONFLICT -> - throw internalServerError("Bonus impossible: admin has insufficient balance.") - Database.BankTransactionResult.SUCCESS -> {/* continue the execution */} + when (db.bankTransactionCreate(adminPaysBonus)) { + Database.BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") + + Database.BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.") + + Database.BankTransactionResult.CONFLICT -> throw internalServerError("Bonus impossible: admin has insufficient balance.") + + Database.BankTransactionResult.SUCCESS -> {/* continue the execution */ + } } } call.respond(HttpStatusCode.Created) return@post } + get("/accounts/{USERNAME}") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized("Login failed") + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed") val resourceName = call.maybeUriComponent("USERNAME") ?: throw badRequest( - hint = "No username found in the URI", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING + hint = "No username found in the URI", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING ) // Checking resource name only if Basic auth was used. // Successful tokens do not need this check, they just pass. - if ( - ((c.login != resourceName) - && (c.login != "admin")) - && (call.getAuthToken() == null) + if (((c.login != resourceName) && (c.login != "admin")) && (call.getAuthToken() == null)) throw forbidden("No rights on the resource.") + val customerData = db.customerGetFromLogin(c.login) + ?: throw internalServerError("Customer '${c.login} despite being authenticated.'") + val customerInternalId = customerData.dbRowId + ?: throw internalServerError("Customer '${c.login} had no row ID despite it was found in the database.'") + val bankAccountData = db.bankAccountGetFromOwnerId(customerInternalId) + ?: throw internalServerError("Customer '${c.login} had no bank account despite they are customer.'") + val balance = Balance( + amount = bankAccountData.balance.toString(), + credit_debit_indicator = if (bankAccountData.hasDebt) { "debit" } else { "credit" } + ) + call.respond( + AccountData( + name = customerData.name, + balance = balance, + debit_threshold = bankAccountData.maxDebt.toString(), + payto_uri = bankAccountData.internalPaytoUri, + contact_data = ChallengeContactData( + email = customerData.email, phone = customerData.phone + ), + cashout_payto_uri = customerData.cashoutPayto, ) - throw forbidden("No rights on the resource.") - val customerData = db.customerGetFromLogin(c.login) ?: throw internalServerError("Customer '${c.login} despite being authenticated.'") - val customerInternalId = customerData.dbRowId ?: throw internalServerError("Customer '${c.login} had no row ID despite it was found in the database.'") - val bankAccountData = db.bankAccountGetFromOwnerId(customerInternalId) ?: throw internalServerError("Customer '${c.login} had no bank account despite they are customer.'") - call.respond(AccountData( - name = customerData.name, - balance = bankAccountData.balance.toString(), - debit_threshold = bankAccountData.maxDebt.toString(), - payto_uri = bankAccountData.internalPaytoUri, - contact_data = ChallengeContactData( - email = customerData.email, - phone = customerData.phone - ), - cashout_payto_uri = customerData.cashoutPayto, - has_debit = bankAccountData.hasDebt - )) + ) return@get } post("/accounts/{USERNAME}/withdrawals") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() // Admin not allowed to withdraw in the name of customers: val accountName = call.expectUriComponent("USERNAME") - if (c.login != accountName) - throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'") + if (c.login != accountName) throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'") val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds. val b = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("Customer '${c.login}' lacks bank account.") val withdrawalAmount = parseTalerAmount(req.amount) - if ( - !isBalanceEnough( - balance = b.expectBalance(), - due = withdrawalAmount, - maxDebt = b.maxDebt, - hasBalanceDebt = b.hasDebt - )) - throw forbidden( - hint = "Insufficient funds to withdraw with Taler", - talerErrorCode = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. + if (!isBalanceEnough( + balance = b.expectBalance(), due = withdrawalAmount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt ) + ) throw forbidden( + hint = "Insufficient funds to withdraw with Taler", + talerErrorCode = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. + ) // Auth and funds passed, create the operation now! val opId = UUID.randomUUID() - if( - !db.talerWithdrawalCreate( - opId, - b.expectRowId(), - withdrawalAmount + if (!db.talerWithdrawalCreate( + opId, b.expectRowId(), withdrawalAmount ) - ) - throw internalServerError("Bank failed at creating the withdraw operation.") + ) throw internalServerError("Bank failed at creating the withdraw operation.") - val bankBaseUrl = call.request.getBaseUrl() - ?: throw internalServerError("Bank could not find its own base URL") - call.respond(BankAccountCreateWithdrawalResponse( - withdrawal_id = opId.toString(), - taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) - )) + val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") + call.respond( + BankAccountCreateWithdrawalResponse( + withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) + ) + ) return@post } - get("/accounts/{USERNAME}/withdrawals/{withdrawal_id}") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() - val accountName = call.expectUriComponent("USERNAME") - // Admin allowed to see the details - if (c.login != accountName && c.login != "admin") throw forbidden() - // Permissions passed, get the information. + + get("/withdrawals/{withdrawal_id}") { val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - call.respond(BankAccountGetWithdrawalResponse( - amount = op.amount.toString(), - aborted = op.aborted, - confirmation_done = op.confirmationDone, - selection_done = op.selectionDone, - selected_exchange_account = op.selectedExchangePayto, - selected_reserve_pub = op.reservePub - )) + call.respond( + BankAccountGetWithdrawalResponse( + amount = op.amount.toString(), + aborted = op.aborted, + confirmation_done = op.confirmationDone, + selection_done = op.selectionDone, + selected_exchange_account = op.selectedExchangePayto, + selected_reserve_pub = op.reservePub + ) + ) return@get } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + + post("/withdrawals/{withdrawal_id}/abort") { + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() // Admin allowed to abort. if (!call.getResourceName("USERNAME").canI(c)) throw forbidden() val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) @@ -308,52 +283,47 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { } // Op is found, it'll now fail only if previously confirmed (DB checks). if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( - hint = "Cannot abort confirmed withdrawal", - talerEc = TalerErrorCode.TALER_EC_END + hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END ) call.respondText("{}", ContentType.Application.Json) return@post } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + + post("/withdrawals/{withdrawal_id}/confirm") { + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() // No admin allowed. - if(!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() + if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency: if (op.confirmationDone) { call.respondText("{}", ContentType.Application.Json) return@post } - if (op.aborted) - throw conflict( - hint = "Cannot confirm an aborted withdrawal", - talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT - ) + if (op.aborted) throw conflict( + hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT + ) // Checking that reserve GOT indeed selected. - if (!op.selectionDone) - throw LibeufinBankException( - httpStatus = HttpStatusCode.UnprocessableEntity, - talerError = TalerError( - hint = "Cannot confirm an unselected withdrawal", - code = TalerErrorCode.TALER_EC_END.code - )) - /* Confirmation conditions are all met, now put the operation + if (!op.selectionDone) throw LibeufinBankException( + httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError( + hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code + ) + )/* Confirmation conditions are all met, now put the operation * to the selected state _and_ wire the funds to the exchange. * Note: 'when' helps not to omit more result codes, should more * be added. */ when (db.talerWithdrawalConfirm(op.withdrawalUuid, getNowUs())) { - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_END // FIXME: define EC for this. - ) + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient funds", TalerErrorCode.TALER_EC_END // FIXME: define EC for this. + ) + WithdrawalConfirmationResult.OP_NOT_FOUND -> /** * Despite previous checks, the database _still_ did not * find the withdrawal operation, that's on the bank. */ throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") + WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> /** * That can happen because the bank did not check the exchange @@ -361,22 +331,20 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { * bank account got removed before this confirmation. */ throw conflict( - hint = "Exchange to withdraw from not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME - ) - WithdrawalConfirmationResult.CONFLICT -> - throw internalServerError("Bank didn't check for idempotency") - WithdrawalConfirmationResult.SUCCESS -> - call.respondText( - "{}", - ContentType.Application.Json + hint = "Exchange to withdraw from not found", talerEc = TalerErrorCode.TALER_EC_END // FIXME ) + + WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") + + WithdrawalConfirmationResult.SUCCESS -> call.respondText( + "{}", ContentType.Application.Json + ) } return@post } get("/accounts/{USERNAME}/transactions") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() val resourceName = call.expectUriComponent("USERNAME") if (c.login != resourceName && c.login != "admin") throw forbidden() // Collecting params. @@ -386,34 +354,34 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { ?: throw internalServerError("Customer '${c.login}' lacks bank account.") val bankAccountId = bankAccount.expectRowId() val history: List<BankAccountTransaction> = db.bankTransactionGetHistory( - start = historyParams.start, - delta = historyParams.delta, - bankAccountId = bankAccountId + start = historyParams.start, delta = historyParams.delta, bankAccountId = bankAccountId ) val res = BankAccountTransactionsResponse(transactions = mutableListOf()) history.forEach { - res.transactions.add(BankAccountTransactionInfo( - debtor_payto_uri = it.debtorPaytoUri, - creditor_payto_uri = it.creditorPaytoUri, - subject = it.subject, - amount = it.amount.toString(), - direction = it.direction, - date = it.transactionDate, - row_id = it.dbRowId ?: throw internalServerError( - "Transaction timestamped with '${it.transactionDate}' did not have row ID" + res.transactions.add( + BankAccountTransactionInfo( + debtor_payto_uri = it.debtorPaytoUri, + creditor_payto_uri = it.creditorPaytoUri, + subject = it.subject, + amount = it.amount.toString(), + direction = it.direction, + date = it.transactionDate, + row_id = it.dbRowId ?: throw internalServerError( + "Transaction timestamped with '${it.transactionDate}' did not have row ID" + ) ) - )) + ) } call.respond(res) return@get } + // Creates a bank transaction. post("/accounts/{USERNAME}/transactions") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here. - if ((c.login != resourceName) && (call.getAuthToken() == null)) - throw forbidden() + if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden() val txData = call.receive<BankAccountTransactionCreate>() // FIXME: make payto parser IBAN-agnostic? val payto = parsePayto(txData.payto_uri) ?: throw badRequest("Invalid creditor Payto") @@ -422,17 +390,13 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val debtorId = c.dbRowId ?: throw internalServerError("Debtor database ID not found") // This performs already a SELECT on the bank account, // like the wire transfer will do as well later! - val creditorCustomerData = db.bankAccountGetFromInternalPayto(paytoWithoutParams) - ?: throw notFound( - "Creditor account not found", - TalerErrorCode.TALER_EC_END // FIXME: define this EC. - ) + val creditorCustomerData = db.bankAccountGetFromInternalPayto(paytoWithoutParams) ?: throw notFound( + "Creditor account not found", TalerErrorCode.TALER_EC_END // FIXME: define this EC. + ) val amount = parseTalerAmount(txData.amount) - if (amount.currency != ctx.currency) - throw badRequest( - "Wrong currency: ${amount.currency}", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) + if (amount.currency != ctx.currency) throw badRequest( + "Wrong currency: ${amount.currency}", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) val dbInstructions = BankInternalTransaction( debtorAccountId = debtorId, creditorAccountId = creditorCustomerData.owningCustomerId, @@ -441,26 +405,25 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { transactionDate = getNowUs() ) val res = db.bankTransactionCreate(dbInstructions) - when(res) { - Database.BankTransactionResult.CONFLICT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_END // FIXME: need bank 'insufficient funds' EC. - ) - Database.BankTransactionResult.NO_CREDITOR -> - throw internalServerError("Creditor not found despite previous checks.") - Database.BankTransactionResult.NO_DEBTOR -> - throw internalServerError("Debtor not found despite the request was authenticated.") + when (res) { + Database.BankTransactionResult.CONFLICT -> throw conflict( + "Insufficient funds", TalerErrorCode.TALER_EC_END // FIXME: need bank 'insufficient funds' EC. + ) + + Database.BankTransactionResult.NO_CREDITOR -> throw internalServerError("Creditor not found despite previous checks.") + + Database.BankTransactionResult.NO_DEBTOR -> throw internalServerError("Debtor not found despite the request was authenticated.") + Database.BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) } return@post } + get("/accounts/{USERNAME}/transactions/{T_ID}") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() val accountOwner = call.expectUriComponent("USERNAME") // auth ok, check rights. - if (c.login != "admin" && c.login != accountOwner) - throw forbidden() + if (c.login != "admin" && c.login != accountOwner) throw forbidden() // rights ok, check tx exists. val tId = call.expectUriComponent("T_ID") val txRowId = try { @@ -470,25 +433,24 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { throw badRequest("TRANSACTION_ID is not a number: ${tId}") } val customerRowId = c.dbRowId ?: throw internalServerError("Authenticated client lacks database entry") - val tx = db.bankTransactionGetFromInternalId(txRowId) - ?: throw notFound( - "Bank transaction '$tId' not found", - TalerErrorCode.TALER_EC_NONE // FIXME: need def. - ) + val tx = db.bankTransactionGetFromInternalId(txRowId) ?: throw notFound( + "Bank transaction '$tId' not found", TalerErrorCode.TALER_EC_NONE // FIXME: need def. + ) val customerBankAccount = db.bankAccountGetFromOwnerId(customerRowId) ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - if (tx.bankAccountId != customerBankAccount.bankAccountId) - throw forbidden("Client has no rights over the bank transaction: $tId") + if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId") // auth and rights, respond. - call.respond(BankAccountTransactionInfo( - amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", - creditor_payto_uri = tx.creditorPaytoUri, - debtor_payto_uri = tx.debtorPaytoUri, - date = tx.transactionDate, - direction = tx.direction, - subject = tx.subject, - row_id = txRowId - )) + call.respond( + BankAccountTransactionInfo( + amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", + creditor_payto_uri = tx.creditorPaytoUri, + debtor_payto_uri = tx.debtorPaytoUri, + date = tx.transactionDate, + direction = tx.direction, + subject = tx.subject, + row_id = txRowId + ) + ) return@get } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -165,38 +165,6 @@ object TalerAmountSerializer : KSerializer<TalerAmount> { } } -/** - * This function tries to authenticate the call according - * to the scheme that is mentioned in the Authorization header. - * The allowed schemes are either 'HTTP basic auth' or 'bearer token'. - * - * requiredScope can be either "readonly" or "readwrite". - * - * Returns the authenticated customer, or null if they failed. - */ -fun ApplicationCall.myAuth(db: Database, requiredScope: TokenScope): Customer? { - // Extracting the Authorization header. - val header = getAuthorizationRawHeader(this.request) ?: throw badRequest( - "Authorization header not found.", - TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) - val authDetails = getAuthorizationDetails(header) ?: throw badRequest( - "Authorization is invalid.", - TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) - return when (authDetails.scheme) { - "Basic" -> doBasicAuth(db, authDetails.content) - "Bearer" -> doTokenAuth(db, authDetails.content, requiredScope) - else -> throw LibeufinBankException( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code, - hint = "Authorization method wrong or not supported." - ) - ) - } -} - /** * Set up web server handlers for the Taler corebank API. @@ -408,7 +376,7 @@ fun readBankApplicationContextFromConfig(cfg: TalerConfig): BankApplicationConte class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") { private val configFile by option( - "--config", + "--config", "-c", help = "set the configuration file" ) init { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -34,8 +34,9 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) call.respond(TWGConfigResponse(currency = ctx.currency)) return@get } + get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() if (!call.getResourceName("USERNAME").canI(c, withAdmin = true)) throw forbidden() val params = getHistoryParams(call.request) val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) @@ -66,8 +67,9 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) call.respond(resp) return@get } + post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val req = call.receive<TransferRequest>() // Checking for idempotency. @@ -120,8 +122,9 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) )) return@post } + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val req = call.receive<AddIncomingRequest>() val amount = parseTalerAmount(req.amount) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -20,7 +20,6 @@ package tech.libeufin.bank import io.ktor.http.* -import io.ktor.http.cio.* import io.ktor.server.application.* import io.ktor.server.plugins.* import io.ktor.server.request.* @@ -30,10 +29,8 @@ import net.taler.wallet.crypto.Base32Crockford import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.* -import java.lang.NumberFormatException import java.net.URL import java.util.* -import kotlin.system.exitProcess const val FRACTION_BASE = 100000000 @@ -41,23 +38,20 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.helpers fun ApplicationCall.expectUriComponent(componentName: String) = this.maybeUriComponent(componentName) ?: throw badRequest( - hint = "No username found in the URI", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING -) + hint = "No username found in the URI", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING + ) + // Get the auth token (stripped of the bearer-token:-prefix) // IF the call was authenticated with it. fun ApplicationCall.getAuthToken(): String? { val h = getAuthorizationRawHeader(this.request) ?: return null val authDetails = getAuthorizationDetails(h) ?: throw badRequest( - "Authorization header is malformed.", + "Authorization header is malformed.", TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + if (authDetails.scheme == "Bearer") return splitBearerToken(authDetails.content) ?: throw throw badRequest( + "Authorization header is malformed (could not strip the prefix from Bearer token).", TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED ) - if (authDetails.scheme == "Bearer") - return splitBearerToken(authDetails.content) ?: throw - throw badRequest( - "Authorization header is malformed (could not strip the prefix from Bearer token).", - TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) return null // Not a Bearer token case. } @@ -76,14 +70,12 @@ fun doBasicAuth(db: Database, encodedCredentials: String): Customer? { */ limit = 2 ) - if (userAndPassSplit.size != 2) - throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED.code, - "Malformed Basic auth credentials found in the Authorization header." - ) + if (userAndPassSplit.size != 2) throw LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED.code, + "Malformed Basic auth credentials found in the Authorization header." ) + ) val login = userAndPassSplit[0] val plainPassword = userAndPassSplit[1] val maybeCustomer = db.customerGetFromLogin(login) ?: throw unauthorized() @@ -111,15 +103,13 @@ fun doTokenAuth( requiredScope: TokenScope, ): Customer? { val bareToken = splitBearerToken(token) ?: throw badRequest( - "Bearer token malformed", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + "Bearer token malformed", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED ) val tokenBytes = try { Base32Crockford.decode(bareToken) } catch (e: Exception) { throw badRequest( - e.message, - TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + e.message, TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED ) } val maybeToken: BearerToken? = db.bearerTokenGet(tokenBytes) @@ -140,87 +130,67 @@ fun doTokenAuth( return null } // Getting the related username. - return db.customerGetFromRowId(maybeToken.bankCustomer) - ?: throw LibeufinBankException( - httpStatus = HttpStatusCode.InternalServerError, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE.code, - hint = "Customer not found, despite token mentions it.", - )) + return db.customerGetFromRowId(maybeToken.bankCustomer) ?: throw LibeufinBankException( + httpStatus = HttpStatusCode.InternalServerError, talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE.code, + hint = "Customer not found, despite token mentions it.", + ) + ) } fun forbidden( hint: String = "No rights on the resource", // FIXME: create a 'generic forbidden' Taler EC. talerErrorCode: TalerErrorCode = TalerErrorCode.TALER_EC_END -): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.Forbidden, - talerError = TalerError( - code = talerErrorCode.code, - hint = hint - ) +): LibeufinBankException = LibeufinBankException( + httpStatus = HttpStatusCode.Forbidden, talerError = TalerError( + code = talerErrorCode.code, hint = hint ) +) -fun unauthorized(hint: String = "Login failed"): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.code, - hint = hint - ) +fun unauthorized(hint: String = "Login failed"): LibeufinBankException = LibeufinBankException( + httpStatus = HttpStatusCode.Unauthorized, talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.code, hint = hint ) -fun internalServerError(hint: String?): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.InternalServerError, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE.code, - hint = hint - ) +) + +fun internalServerError(hint: String?): LibeufinBankException = LibeufinBankException( + httpStatus = HttpStatusCode.InternalServerError, talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE.code, hint = hint ) +) fun notFound( - hint: String?, - talerEc: TalerErrorCode -): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.NotFound, - talerError = TalerError( - code = talerEc.code, - hint = hint - ) + hint: String?, talerEc: TalerErrorCode +): LibeufinBankException = LibeufinBankException( + httpStatus = HttpStatusCode.NotFound, talerError = TalerError( + code = talerEc.code, hint = hint ) +) fun conflict( - hint: String?, - talerEc: TalerErrorCode -): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( - code = talerEc.code, - hint = hint - ) + hint: String?, talerEc: TalerErrorCode +): LibeufinBankException = LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, talerError = TalerError( + code = talerEc.code, hint = hint ) +) + fun badRequest( - hint: String? = null, - talerErrorCode: TalerErrorCode = TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID -): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = talerErrorCode.code, - hint = hint - ) + hint: String? = null, talerErrorCode: TalerErrorCode = TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID +): LibeufinBankException = LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, talerError = TalerError( + code = talerErrorCode.code, hint = hint ) +) + // Generates a new Payto-URI with IBAN scheme. fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}" // Parses Taler amount, returning null if the input is invalid. fun parseTalerAmount2( - amount: String, - fracDigits: FracDigits + amount: String, fracDigits: FracDigits ): TalerAmount? { val format = when (fracDigits) { FracDigits.TWO -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?)?$" @@ -246,11 +216,10 @@ fun parseTalerAmount2( return null } return TalerAmount( - value = value, - frac = fraction, - currency = match.destructured.component1() + value = value, frac = fraction, currency = match.destructured.component1() ) } + /** * This helper takes the serialized version of a Taler Amount * type and parses it into Libeufin's internal representation. @@ -260,16 +229,13 @@ fun parseTalerAmount2( * responded to the client. */ fun parseTalerAmount( - amount: String, - fracDigits: FracDigits = FracDigits.EIGHT + amount: String, fracDigits: FracDigits = FracDigits.EIGHT ): TalerAmount { - val maybeAmount = parseTalerAmount2(amount, fracDigits) - ?: throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, - hint = "Invalid amount: $amount" - )) + val maybeAmount = parseTalerAmount2(amount, fracDigits) ?: throw LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, hint = "Invalid amount: $amount" + ) + ) return maybeAmount } @@ -278,9 +244,7 @@ private fun normalizeAmount(amt: TalerAmount): TalerAmount { val normalValue = amt.value + (amt.frac / FRACTION_BASE) val normalFrac = amt.frac % FRACTION_BASE return TalerAmount( - value = normalValue, - frac = normalFrac, - currency = amt.currency + value = normalValue, frac = normalFrac, currency = amt.currency ) } return amt @@ -289,22 +253,19 @@ private fun normalizeAmount(amt: TalerAmount): TalerAmount { // Adds two amounts and returns the normalized version. private fun amountAdd(first: TalerAmount, second: TalerAmount): TalerAmount { - if (first.currency != second.currency) - throw badRequest( - "Currency mismatch, balance '${first.currency}', price '${second.currency}'", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) + if (first.currency != second.currency) throw badRequest( + "Currency mismatch, balance '${first.currency}', price '${second.currency}'", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) val valueAdd = first.value + second.value - if (valueAdd < first.value) - throw badRequest("Amount value overflowed") + if (valueAdd < first.value) throw badRequest("Amount value overflowed") val fracAdd = first.frac + second.frac - if (fracAdd < first.frac) - throw badRequest("Amount fraction overflowed") - return normalizeAmount(TalerAmount( - value = valueAdd, - frac = fracAdd, - currency = first.currency - )) + if (fracAdd < first.frac) throw badRequest("Amount fraction overflowed") + return normalizeAmount( + TalerAmount( + value = valueAdd, frac = fracAdd, currency = first.currency + ) + ) } /** @@ -315,20 +276,13 @@ private fun amountAdd(first: TalerAmount, second: TalerAmount): TalerAmount { * the database. */ fun isBalanceEnough( - balance: TalerAmount, - due: TalerAmount, - maxDebt: TalerAmount, - hasBalanceDebt: Boolean + balance: TalerAmount, due: TalerAmount, maxDebt: TalerAmount, hasBalanceDebt: Boolean ): Boolean { val normalMaxDebt = normalizeAmount(maxDebt) // Very unlikely to be needed. if (hasBalanceDebt) { val chargedBalance = amountAdd(balance, due) if (chargedBalance.value > normalMaxDebt.value) return false // max debt surpassed - if ( - (chargedBalance.value == normalMaxDebt.value) && - (chargedBalance.frac > maxDebt.frac) - ) - return false + if ((chargedBalance.value == normalMaxDebt.value) && (chargedBalance.frac > maxDebt.frac)) return false return true } /** @@ -336,19 +290,17 @@ fun isBalanceEnough( * block calculates how much debt the balance would get, should a * subtraction of 'due' occur. */ - if (balance.currency != due.currency) - throw badRequest( - "Currency mismatch, balance '${balance.currency}', due '${due.currency}'", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) + if (balance.currency != due.currency) throw badRequest( + "Currency mismatch, balance '${balance.currency}', due '${due.currency}'", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) val valueDiff = if (balance.value < due.value) due.value - balance.value else 0L val fracDiff = if (balance.frac < due.frac) due.frac - balance.frac else 0 // Getting the normalized version of such diff. val normalDiff = normalizeAmount(TalerAmount(valueDiff, fracDiff, balance.currency)) // Failing if the normalized diff surpasses the max debt. if (normalDiff.value > normalMaxDebt.value) return false - if ((normalDiff.value == normalMaxDebt.value) && - (normalDiff.frac > normalMaxDebt.frac)) return false + if ((normalDiff.value == normalMaxDebt.value) && (normalDiff.frac > normalMaxDebt.frac)) return false return true } @@ -360,47 +312,41 @@ fun isBalanceEnough( * * https://$BANK_URL/taler-integration */ -fun getTalerWithdrawUri(baseUrl: String, woId: String) = - url { - val baseUrlObj = URL(baseUrl) - protocol = URLProtocol( - name = "taler".plus(if (baseUrlObj.protocol.lowercase() == "http") "+http" else ""), - defaultPort = -1 - ) - host = "withdraw" - val pathSegments = mutableListOf( - // adds the hostname(+port) of the actual bank that will serve the withdrawal request. - baseUrlObj.host.plus( - if (baseUrlObj.port != -1) - ":${baseUrlObj.port}" - else "" - ) +fun getTalerWithdrawUri(baseUrl: String, woId: String) = url { + val baseUrlObj = URL(baseUrl) + protocol = URLProtocol( + name = "taler".plus(if (baseUrlObj.protocol.lowercase() == "http") "+http" else ""), defaultPort = -1 + ) + host = "withdraw" + val pathSegments = mutableListOf( + // adds the hostname(+port) of the actual bank that will serve the withdrawal request. + baseUrlObj.host.plus( + if (baseUrlObj.port != -1) ":${baseUrlObj.port}" + else "" ) - // Removing potential double slashes. - baseUrlObj.path.split("/").forEach { - if (it.isNotEmpty()) pathSegments.add(it) - } - pathSegments.add("taler-integration/${woId}") - this.appendPathSegments(pathSegments) + ) + // Removing potential double slashes. + baseUrlObj.path.split("/").forEach { + if (it.isNotEmpty()) pathSegments.add(it) } + pathSegments.add("taler-integration/${woId}") + this.appendPathSegments(pathSegments) +} // Builds a withdrawal confirm URL. fun getWithdrawalConfirmUrl( - baseUrl: String, - wopId: String, - username: String - ) = - url { - val baseUrlObj = URL(baseUrl) - protocol = URLProtocol(name = baseUrlObj.protocol, defaultPort = -1) - host = baseUrlObj.host - // Removing potential double slashes: - baseUrlObj.path.split("/").forEach { - this.appendPathSegments(it) - } - // Completing the endpoint: - this.appendPathSegments("accounts/${username}/withdrawals/${wopId}/confirm") + baseUrl: String, wopId: String, username: String +) = url { + val baseUrlObj = URL(baseUrl) + protocol = URLProtocol(name = baseUrlObj.protocol, defaultPort = -1) + host = baseUrlObj.host + // Removing potential double slashes: + baseUrlObj.path.split("/").forEach { + this.appendPathSegments(it) } + // Completing the endpoint: + this.appendPathSegments("accounts/${username}/withdrawals/${wopId}/confirm") +} /** @@ -417,24 +363,23 @@ fun getWithdrawal(db: Database, opIdParam: String): TalerWithdrawalOperation { logger.error(e.message) throw badRequest("withdrawal_id query parameter was malformed") } - val op = db.talerWithdrawalGet(opId) - ?: throw notFound( - hint = "Withdrawal operation $opIdParam not found", - talerEc = TalerErrorCode.TALER_EC_END - ) + val op = db.talerWithdrawalGet(opId) ?: throw notFound( + hint = "Withdrawal operation $opIdParam not found", talerEc = TalerErrorCode.TALER_EC_END + ) return op } data class HistoryParams( - val delta: Long, - val start: Long + val delta: Long, val start: Long ) + /** * Extracts the query parameters from "history-like" endpoints, * providing the defaults according to the API specification. */ fun getHistoryParams(req: ApplicationRequest): HistoryParams { - val deltaParam: String = req.queryParameters["delta"] ?: throw MissingRequestParameterException(parameterName = "delta") + val deltaParam: String = + req.queryParameters["delta"] ?: throw MissingRequestParameterException(parameterName = "delta") val delta: Long = try { deltaParam.toLong() } catch (e: Exception) { @@ -473,8 +418,7 @@ fun maybeCreateAdminAccount(db: Database, ctx: BankApplicationContext): Boolean * Hashing the password helps to avoid the "password not hashed" * error, in case the admin tries to authenticate. */ - passwordHash = CryptoUtil.hashpw(String(pwBuf, Charsets.UTF_8)), - name = "Bank administrator" + passwordHash = CryptoUtil.hashpw(String(pwBuf, Charsets.UTF_8)), name = "Bank administrator" ) val rowId = db.customerCreate(adminCustomer) if (rowId == null) { @@ -482,9 +426,7 @@ fun maybeCreateAdminAccount(db: Database, ctx: BankApplicationContext): Boolean return false } rowId - } - else - maybeAdminCustomer.expectRowId() + } else maybeAdminCustomer.expectRowId() val maybeAdminBankAccount = db.bankAccountGetFromOwnerId(adminCustomerId) if (maybeAdminBankAccount == null) { logger.info("Creating admin bank account") diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -1,572 +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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import java.io.Serial -import java.util.* - -// Allowed lengths for fractional digits in amounts. -enum class FracDigits(howMany: Int) { - TWO(2), - EIGHT(8) -} - - -// It contains the number of microseconds since the Epoch. -@Serializable -data class Timestamp( - val t_s: Long // FIXME (?): not supporting "never" at the moment. -) - -/** - * HTTP response type of successful token refresh. - * access_token is the Crockford encoding of the 32 byte - * access token, whereas 'expiration' is the point in time - * when this token expires. - */ -@Serializable -data class TokenSuccessResponse( - val access_token: String, - val expiration: Timestamp -) - -/** - * Error object to respond to the client. The - * 'code' field takes values from the GANA gnu-taler-error-code - * specification. 'hint' is a human-readable description - * of the error. - */ -@Serializable -data class TalerError( - val code: Int, - val hint: String? = null -) - -/* Contains contact data to send TAN challges to the -* users, to let them complete cashout operations. */ -@Serializable -data class ChallengeContactData( - val email: String? = null, - val phone: String? = null -) - -// Type expected at POST /accounts -@Serializable -data class RegisterAccountRequest( - val username: String, - val password: String, - val name: String, - val is_public: Boolean = false, - val is_taler_exchange: Boolean = false, - val challenge_contact_data: ChallengeContactData? = null, - // External bank account where to send cashout amounts. - val cashout_payto_uri: String? = null, - // Bank account internal to Libeufin-Bank. - val internal_payto_uri: String? = null -) - -/* Internal representation of relative times. The -* "forever" case is represented with Long.MAX_VALUE. -*/ -data class RelativeTime( - val d_us: Long -) - -/** - * Type expected at POST /accounts/{USERNAME}/token - * It complies with Taler's design document #49 - */ -@Serializable -data class TokenRequest( - val scope: TokenScope, - @Contextual - val duration: RelativeTime? = null, - val refreshable: Boolean = false -) - -/** - * Convenience type to throw errors along the bank activity - * and that is meant to be caught by Ktor and responded to the - * client. - */ -class LibeufinBankException( - // Status code that Ktor will set for the response. - val httpStatus: HttpStatusCode, - // Error detail object, after Taler API. - val talerError: TalerError -) : Exception(talerError.hint) - -/** - * Convenience type to hold customer data, typically after such - * data gets fetched from the database. It is also used to _insert_ - * customer data to the database. - */ -data class Customer( - val login: String, - val passwordHash: String, - val name: String, - /** - * Only non-null when this object is defined _by_ the - * database. - */ - val dbRowId: Long? = null, - val email: String? = null, - val phone: String? = null, - /** - * External bank account where customers send - * their cashout amounts. - */ - val cashoutPayto: String? = null, - /** - * Currency of the external bank account where - * customers send their cashout amounts. - */ - val cashoutCurrency: String? = null -) - -/** - * Represents a Taler amount. This type can be used both - * to hold database records and amounts coming from the parser. - * If maybeCurrency is null, then the constructor defaults it - * to be the "internal currency". Internal currency is the one - * with which Libeufin-Bank moves funds within itself, therefore - * not to be mistaken with the cashout currency, which is the one - * that gets credited to Libeufin-Bank users to their cashout_payto_uri. - * - * maybeCurrency is typically null when the TalerAmount object gets - * defined by the Database class. - */ -class TalerAmount( - val value: Long, - val frac: Int, - val currency: String -) { - override fun equals(other: Any?): Boolean { - return other is TalerAmount && - other.value == this.value && - other.frac == this.frac && - other.currency == this.currency - } - - override fun toString(): String { - return "$currency:$value.$frac" - } -} - -/** - * Convenience type to get and set bank account information - * from/to the database. - */ -data class BankAccount( - val internalPaytoUri: String, - // Database row ID of the customer that owns this bank account. - val owningCustomerId: Long, - val bankAccountId: Long? = null, // null at INSERT. - val isPublic: Boolean = false, - val isTalerExchange: Boolean = false, - /** - * Because bank accounts MAY be funded by an external currency, - * local bank accounts need to query Nexus, in order to find this - * out. This field is a pointer to the latest incoming payment that - * was contained in a Nexus history response. - * - * Typically, the 'admin' bank account uses this field, in order - * to initiate Taler withdrawals that depend on an external currency - * being wired by wallet owners. - */ - val lastNexusFetchRowId: Long = 0L, - val balance: TalerAmount? = null, // null when a new bank account gets created. - val hasDebt: Boolean, - val maxDebt: TalerAmount -) - -// Allowed values for bank transactions directions. -enum class TransactionDirection { - credit, - debit -} - -// Allowed values for cashout TAN channels. -enum class TanChannel { - sms, - email, - file // Writes cashout TANs to /tmp, for testing. -} - -// Scopes for authentication tokens. -enum class TokenScope { - readonly, - readwrite, - refreshable // Not spec'd as a scope! -} - -/** - * Convenience type to set/get authentication tokens to/from - * the database. - */ -data class BearerToken( - val content: ByteArray, - val scope: TokenScope, - val isRefreshable: Boolean = false, - val creationTime: Long, - val expirationTime: Long, - /** - * Serial ID of the database row that hosts the bank customer - * that is associated with this token. NOTE: if the token is - * refreshed by a client that doesn't have a user+password login - * in the system, the creator remains always the original bank - * customer that created the very first token. - */ - val bankCustomer: Long -) - -/** - * Convenience type to _communicate_ a bank transfer to the - * database procedure, NOT representing therefore any particular - * table. The procedure will then retrieve all the tables data - * from this type. - */ -data class BankInternalTransaction( - // Database row ID of the internal bank account sending the payment. - val creditorAccountId: Long, - // Database row ID of the internal bank account receiving the payment. - val debtorAccountId: Long, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, - val accountServicerReference: String = "not used", // ISO20022 - val endToEndId: String = "not used", // ISO20022 - val paymentInformationId: String = "not used" // ISO20022 -) - -/** - * Convenience type representing bank transactions as they - * are in the respective database table. Only used to _get_ - * the information from the database. - */ -data class BankAccountTransaction( - val creditorPaytoUri: String, - val creditorName: String, - val debtorPaytoUri: String, - val debtorName: String, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, // microseconds - /** - * Is the transaction debit, or credit for the - * bank account pointed by this object? - */ - val direction: TransactionDirection, - /** - * database row ID of the bank account that is - * impacted by the direction. For example, if the - * direction is debit, then this value points to the - * bank account of the payer. - */ - val bankAccountId: Long, - // Null if this type is used to _create_ one transaction. - val dbRowId: Long? = null, - // Following are ISO20022 specific. - val accountServicerReference: String, - val paymentInformationId: String, - val endToEndId: String, -) - -/** - * Represents a Taler withdrawal operation, as it is - * stored in the respective database table. - */ -data class TalerWithdrawalOperation( - val withdrawalUuid: UUID, - val amount: TalerAmount, - val selectionDone: Boolean = false, - val aborted: Boolean = false, - val confirmationDone: Boolean = false, - val reservePub: String?, - val selectedExchangePayto: String?, - val walletBankAccount: Long -) - -/** - * Represents a cashout operation, as it is stored - * in the respective database table. - */ -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 credit_payto_uri: String, - val cashoutCurrency: String -) - -// Type to return as GET /config response -@Serializable // Never used to parse JSON. -data class Config( - val name: String = "libeufin-bank", - val version: String = "0:0:0", - val have_cashout: Boolean = false, - // Following might probably get renamed: - val fiat_currency: String? = null -) - -// GET /accounts/$USERNAME response. -@Serializable -data class AccountData( - val name: String, - val balance: String, - val payto_uri: String, - val debit_threshold: String, - val contact_data: ChallengeContactData? = null, - val cashout_payto_uri: String? = null, - val has_debit: Boolean -) - -// Type of POST /transactions -@Serializable -data class BankAccountTransactionCreate( - val payto_uri: String, - val amount: String -) - -/* History element, either from GET /transactions/T_ID - or from GET /transactions */ -@Serializable -data class BankAccountTransactionInfo( - val creditor_payto_uri: String, - val debtor_payto_uri: String, - val amount: String, - val direction: TransactionDirection, - val subject: String, - val row_id: Long, // is T_ID - val date: Long -) - -// Response type for histories, namely GET /transactions -@Serializable -data class BankAccountTransactionsResponse( - val transactions: MutableList<BankAccountTransactionInfo> -) - -// Taler withdrawal request. -@Serializable -data class BankAccountCreateWithdrawalRequest( - val amount: String -) - -// Taler withdrawal response. -@Serializable -data class BankAccountCreateWithdrawalResponse( - val withdrawal_id: String, - val taler_withdraw_uri: String -) - -// Taler withdrawal details response -@Serializable -data class BankAccountGetWithdrawalResponse( - val amount: String, - val aborted: Boolean, - val confirmation_done: Boolean, - val selection_done: Boolean, - val selected_reserve_pub: String? = null, - val selected_exchange_account: String? = null -) - -typealias ResourceName = String - -/** - * Checks if the input Customer has the rights over ResourceName. - * FIXME: myAuth() gives null on failures, but this gives false. - * Should they return the same, for consistency? - */ -fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { - if (c.login == this) return true - if (c.login == "admin" && withAdmin) return true - return false -} - -/** - * Factors out the retrieval of the resource name from - * the URI. The resource looked for defaults to "USERNAME" - * as this is frequently mentioned resource along the endpoints. - * - * This helper is recommended because it returns a ResourceName - * type that then offers the ".canI()" helper to check if the user - * has the rights on the resource. - */ -fun ApplicationCall.getResourceName(param: String): ResourceName = - this.expectUriComponent(param) - -/** - * This type communicates the result of a database operation - * to confirm one withdrawal operation. - */ -enum class WithdrawalConfirmationResult { - SUCCESS, - OP_NOT_FOUND, - EXCHANGE_NOT_FOUND, - BALANCE_INSUFFICIENT, - - /** - * This state indicates that the withdrawal was already - * confirmed BUT Kotlin did not detect it and still invoked - * the SQL procedure to confirm the withdrawal. This is - * conflictual because only Kotlin is responsible to check - * for idempotency, and this state witnesses a failure in - * this regard. - */ - CONFLICT -} - -// GET /config response from the Taler Integration API. -@Serializable -data class TalerIntegrationConfigResponse( - val name: String = "taler-bank-integration", - val version: String = "0:0:0:", - val currency: String -) - -// Withdrawal status as spec'd in the Taler Integration API. -@Serializable -data class BankWithdrawalOperationStatus( - // Indicates whether the withdrawal was aborted. - val aborted: Boolean, - - /* Has the wallet selected parameters for the withdrawal operation - (exchange and reserve public key) and successfully sent it - to the bank? */ - val selection_done: Boolean, - - /* The transfer has been confirmed and registered by the bank. - Does not guarantee that the funds have arrived at the exchange - already. */ - val transfer_done: Boolean, - - /* Amount that will be withdrawn with this operation - (raw amount without fee considerations). */ - val amount: String, - - /* Bank account of the customer that is withdrawing, as a - ``payto`` URI. */ - val sender_wire: String? = null, - - // Suggestion for an exchange given by the bank. - val suggested_exchange: String? = null, - - /* URL that the user needs to navigate to in order to - complete some final confirmation (e.g. 2FA). - It may contain withdrawal operation id */ - val confirm_transfer_url: String? = null, - - // Wire transfer types supported by the bank. - val wire_types: MutableList<String> = mutableListOf("iban") -) - -// Selection request on a Taler withdrawal. -@Serializable -data class BankWithdrawalOperationPostRequest( - val reserve_pub: String, - val selected_exchange: String, -) - -/** - * Response to the wallet after it selects the exchange - * and the reserve pub. - */ -@Serializable -data class BankWithdrawalOperationPostResponse( - val transfer_done: Boolean, - val confirm_transfer_url: String? = null -) - -/** - * Request to an /admin/add-incoming request from - * the Taler Wire Gateway API. - */ -@Serializable -data class AddIncomingRequest( - val amount: String, - val reserve_pub: String, - val debit_account: String -) - -// Response to /admin/add-incoming -@Serializable -data class AddIncomingResponse( - val timestamp: Long, - val row_id: Long -) - -@Serializable -data class TWGConfigResponse( - val name: String = "taler-wire-gateway", - val version: String = "0:0:0:", - val currency: String -) - -// Response of a TWG /history/incoming call. -@Serializable -data class IncomingHistory( - val incoming_transactions: MutableList<IncomingReserveTransaction> = mutableListOf(), - val credit_account: String // Receiver's Payto URI. -) - -// TWG's incoming payment record. -@Serializable -data class IncomingReserveTransaction( - val type: String = "RESERVE", - val row_id: Long, // DB row ID of the payment. - val date: Long, // microseconds timestamp. - val amount: String, - val debit_account: String, // Payto of the sender. - val reserve_pub: String -) - -// TWG's request to pay a merchant. -@Serializable -data class TransferRequest( - val request_uid: String, - @Contextual - val amount: TalerAmount, - val exchange_base_url: String, - val wtid: String, - val credit_account: String -) - -// TWG's response to merchant payouts -@Serializable -data class TransferResponse( - val timestamp: Long, - val row_id: Long -) -\ No newline at end of file diff --git a/contrib/libeufin-bank.sample.conf b/contrib/libeufin-bank.sample.conf @@ -1,5 +1,5 @@ [libeufin-bank] -currency = KUDOS +CURRENCY = KUDOS DEFAULT_CUSTOMER_DEBT_LIMIT = KUDOS:200 DEFAULT_ADMIN_DEBT_LIMIT = KUDOS:2000 REGISTRATION_BONUS = KUDOS:100 diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -3,9 +3,7 @@ package tech.libeufin.util import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* -import io.ktor.server.response.* import io.ktor.server.util.* -import io.ktor.util.* import logger // Get the base URL of a request, returns null if any problem occurs. @@ -22,13 +20,13 @@ fun ApplicationRequest.getBaseUrl(): String? { prefix += "/" URLBuilder( protocol = URLProtocol( - name = this.headers.get("X-Forwarded-Proto") ?: run { + name = this.headers["X-Forwarded-Proto"] ?: run { logger.error("Reverse proxy did not define X-Forwarded-Proto") return null }, defaultPort = -1 // Port must be specified with X-Forwarded-Host. ), - host = this.headers.get("X-Forwarded-Host") ?: run { + host = this.headers["X-Forwarded-Host"] ?: run { logger.error("Reverse proxy did not define X-Forwarded-Host") return null } @@ -46,10 +44,6 @@ fun ApplicationRequest.getBaseUrl(): String? { } } -/** - * Get the URI (path's) component or throw Internal server error. - * @param component the name of the URI component to return. - */ fun ApplicationCall.maybeUriComponent(name: String): String? { val ret: String? = this.parameters[name] if (ret == null) { @@ -77,6 +71,7 @@ data class AuthorizationDetails( val scheme: String, val content: String ) + // Returns the authorization scheme mentioned in the Auth header, // or null if that could not be found. fun getAuthorizationDetails(authorizationHeader: String): AuthorizationDetails? {