libeufin

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

commit 365df3c31e524a542eab8551d3384fbd32654ec9
parent b90782a78b77816cb5399152e86769c9b9c160cf
Author: MS <ms@taler.net>
Date:   Sun, 17 Sep 2023 10:08:48 +0200

Implementing token authentication.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 152++++++-------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/Helpers.kt | 160-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 137+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/bankTypes.kt | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/DatabaseTest.kt | 8+++-----
Mbank/src/test/kotlin/JsonTest.kt | 2+-
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mdatabase-versioning/libeufin-bank-0001.sql | 1+
Dutil/src/main/kotlin/LibeufinErrorCodes.kt | 78------------------------------------------------------------------------------
Mutil/src/main/kotlin/TalerErrorCode.kt | 2--
Mutil/src/main/kotlin/time.kt | 9+++------
12 files changed, 711 insertions(+), 450 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -28,143 +28,9 @@ import java.util.* private const val DB_CTR_LIMIT = 1000000 -data class Customer( - val login: String, - val passwordHash: String, - val name: String, - val dbRowId: Long? = null, // mostly used when retrieving records. - val email: String? = null, - val phone: String? = null, - val cashoutPayto: String? = null, - val cashoutCurrency: String? = null -) -fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '$login' had no DB row ID") - -/** - * 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, - maybeCurrency: String? = null -) { - val currency: String = if (maybeCurrency == null) { - val internalCurrency = db.configGet("internal_currency") - ?: throw internalServerError("internal_currency not found in the config") - internalCurrency - } else maybeCurrency - - override fun equals(other: Any?): Boolean { - return other is TalerAmount && - other.value == this.value && - other.frac == this.frac && - other.currency == this.currency - } -} -// BIC got removed, because it'll be expressed in the internal_payto_uri. -data class BankAccount( - val internalPaytoUri: String, - val owningCustomerId: Long, - val isPublic: Boolean = false, - val isTalerExchange: Boolean = false, - val lastNexusFetchRowId: Long = 0L, - val balance: TalerAmount? = null, - val hasDebt: Boolean, - val maxDebt: TalerAmount -) - -enum class TransactionDirection { - credit, debit -} - -enum class TanChannel { - sms, email, file -} - -enum class TokenScope { - readonly, readwrite -} - -data class BearerToken( - val content: ByteArray, - val scope: TokenScope, - 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 -) - -data class BankInternalTransaction( - val creditorAccountId: Long, - val debtorAccountId: Long, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, - val accountServicerReference: String, - val endToEndId: String, - val paymentInformationId: String -) - -data class BankAccountTransaction( - val creditorPaytoUri: String, - val creditorName: String, - val debtorPaytoUri: String, - val debtorName: String, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, // microseconds - val accountServicerReference: String, - val paymentInformationId: String, - val endToEndId: String, - val direction: TransactionDirection, - val bankAccountId: Long, -) - -data class TalerWithdrawalOperation( - val withdrawalUuid: UUID, - val amount: TalerAmount, - val selectionDone: Boolean = false, - val aborted: Boolean = false, - val confirmationDone: Boolean = false, - val reservePub: ByteArray?, - val selectedExchangePayto: String?, - val walletBankAccount: Long -) +fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '$login' had no DB row ID") -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 -) class Database(private val dbConfig: String) { private var dbConn: PgConnection? = null @@ -306,7 +172,8 @@ class Database(private val dbConfig: String) { phone = it.getString("phone"), email = it.getString("email"), cashoutCurrency = it.getString("cashout_currency"), - cashoutPayto = it.getString("cashout_payto") + cashoutPayto = it.getString("cashout_payto"), + dbRowId = customer_id ) } } @@ -351,16 +218,17 @@ class Database(private val dbConfig: String) { creation_time, expiration_time, scope, - bank_customer + bank_customer, + is_refreshable ) VALUES - (?, ?, ?, ?::token_scope_enum, ?) + (?, ?, ?, ?::token_scope_enum, ?, ?) """) stmt.setBytes(1, token.content) stmt.setLong(2, token.creationTime) stmt.setLong(3, token.expirationTime) stmt.setString(4, token.scope.name) stmt.setLong(5, token.bankCustomer) - + stmt.setBoolean(6, token.isRefreshable) return myExecute(stmt) } fun bearerTokenGet(token: ByteArray): BearerToken? { @@ -370,7 +238,8 @@ class Database(private val dbConfig: String) { expiration_time, creation_time, bank_customer, - scope + scope, + is_refreshable FROM bearer_tokens WHERE content=?; """) @@ -387,7 +256,8 @@ class Database(private val dbConfig: String) { if (this == TokenScope.readwrite.name) return@run TokenScope.readwrite if (this == TokenScope.readonly.name) return@run TokenScope.readonly else throw internalServerError("Wrong token scope found in the database: $this") - } + }, + isRefreshable = it.getBoolean("is_refreshable") ) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt @@ -1,159 +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 net.taler.common.errorcodes.TalerErrorCode -import tech.libeufin.util.* -import java.lang.NumberFormatException - -// HELPERS. - -/** - * Performs the HTTP basic authentication. Returns the - * authenticated customer on success, or null otherwise. - */ -fun doBasicAuth(encodedCredentials: String): Customer? { - val plainUserAndPass = String(base64ToBytes(encodedCredentials), Charsets.UTF_8) // :-separated - val userAndPassSplit = plainUserAndPass.split( - ":", - /** - * this parameter allows colons to occur in passwords. - * Without this, passwords that have colons would be split - * and become meaningless. - */ - 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." - ) - ) - val login = userAndPassSplit[0] - val plainPassword = userAndPassSplit[1] - val maybeCustomer = db.customerGetFromLogin(login) ?: return null - if (!CryptoUtil.checkpw(plainPassword, maybeCustomer.passwordHash)) return null - return maybeCustomer -} - -/* Performs the bearer-token authentication. Returns the - * authenticated customer on success, null otherwise. */ -fun doTokenAuth( - token: String, - requiredScope: TokenScope, // readonly or readwrite -): Customer? { - val maybeToken: BearerToken = db.bearerTokenGet(token.toByteArray(Charsets.UTF_8)) ?: return null - val isExpired: Boolean = maybeToken.expirationTime - getNow().toMicro() < 0 - if (isExpired || maybeToken.scope != requiredScope) return null // FIXME: mention the reason? - // 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.", - )) -} - -fun unauthorized(hint: String? = null): 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 badRequest( - hint: String? = null, - talerErrorCode: TalerErrorCode = TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID -): LibeufinBankException = - LibeufinBankException( - httpStatus = HttpStatusCode.InternalServerError, - talerError = TalerError( - code = talerErrorCode.code, - hint = hint - ) - ) -// Generates a new Payto-URI with IBAN scheme. -fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}" - -/** - * This helper takes the serialized version of a Taler Amount - * type and parses it into Libeufin's internal representation. - * It returns a TalerAmount type, or throws a LibeufinBankException - * it the input is invalid. Such exception will be then caught by - * Ktor, transformed into the appropriate HTTP error type, and finally - * responded to the client. - */ -fun parseTalerAmount( - amount: String, - fracDigits: FracDigits = FracDigits.EIGHT -): TalerAmount { - val format = when (fracDigits) { - FracDigits.TWO -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?)?$" - FracDigits.EIGHT -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?)?$" - } - val match = Regex(format).find(amount) ?: throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, - hint = "Invalid amount: $amount" - )) - val _value = match.destructured.component2() - // Fraction is at most 8 digits, so it's always < than MAX_INT. - val fraction: Int = match.destructured.component3().run { - var frac = 0 - var power = 100000000 - if (this.isNotEmpty()) - // Skips the dot and processes the fractional chars. - this.substring(1).forEach { chr -> - power /= 10 - frac += power * chr.digitToInt() - } - return@run frac - } - val value: Long = try { - _value.toLong() - } catch (e: NumberFormatException) { - throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, - hint = "Invalid amount: ${amount}, could not extract the value part." - ) - ) - } - return TalerAmount( - value = value, - frac = fraction, - maybeCurrency = match.destructured.component1() - ) -} -\ 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 @@ -36,57 +36,23 @@ import io.ktor.server.routing.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.json.* +import kotlinx.serialization.modules.SerializersModule import net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level import tech.libeufin.util.* +import java.time.Duration +import kotlin.random.Random // GLOBALS val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank") val db = Database(System.getProperty("BANK_DB_CONNECTION_STRING")) const val GENERIC_UNDEFINED = -1 // Filler for ECs that don't exist yet. +val TOKEN_DEFAULT_DURATION_US = Duration.ofDays(1L).seconds * 1000000 -// TYPES - -// FIXME: double-check the enum numeric value. -enum class FracDigits(howMany: Int) { - TWO(2), - EIGHT(8) -} - -@Serializable -data class TalerError( - val code: Int, - val hint: String? = null -) - -@Serializable -data class ChallengeContactData( - val email: String? = null, - val phone: String? = null -) -@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, - val cashout_payto_uri: String? = null, - val internal_payto_uri: String? = null -) - -/** - * This is the _internal_ representation of a RelativeTime - * JSON type. - */ -data class RelativeTime( - val d_us: Long -) /** * This custom (de)serializer interprets the RelativeTime JSON @@ -122,17 +88,7 @@ object RelativeTimeSerializer : KSerializer<RelativeTime> { element<JsonElement>("d_us") } } -@Serializable -data class TokenRequest( - val scope: TokenScope, - @Contextual - val duration: RelativeTime -) -class LibeufinBankException( - val httpStatus: HttpStatusCode, - val talerError: TalerError -) : Exception(talerError.hint) /** * This function tries to authenticate the call according @@ -187,7 +143,12 @@ val webApp: Application.() -> Unit = { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true - isLenient = false + // Registering custom parser for RelativeTime + serializersModule = SerializersModule { + contextual(RelativeTime::class) { + RelativeTimeSerializer + } + } }) } install(RequestValidation) @@ -246,12 +207,78 @@ val webApp: Application.() -> Unit = { } } routing { - post("/accounts/{USERNAME}/auth-token") { - val customer = call.myAuth(TokenScope.readwrite) + post("/accounts/{USERNAME}/token") { + val customer = call.myAuth(TokenScope.refreshable) ?: throw unauthorized("Authentication failed") val endpointOwner = call.expectUriComponent("USERNAME") - if (customer == null || customer.login != endpointOwner) - throw unauthorized("Auth failed or client has no rights") - + 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>() + /** + * This block checks permissions ONLY IF the call was authenticated + * with a token. Basic auth gets always granted. + */ + if (maybeAuthToken != null) { + val tokenBytes = Base32Crockford.decode(maybeAuthToken) + 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 + ) + } + val tokenBytes = ByteArray(32).apply { + java.util.Random().nextBytes(this) + } + val maxDurationTime: Long = db.configGet("token_max_duration").run { + if (this == null) + return@run Long.MAX_VALUE + return@run try { + this.toLong() + } catch (e: Exception) { + logger.error("Could not convert config's token_max_duration to Long") + throw internalServerError(e.message) + } + } + if (req.duration != null && req.duration.d_us.compareTo(maxDurationTime) == 1) + 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( + "Coud not resort 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 + ) + val token = BearerToken( + bankCustomer = customerDbRow, + content = tokenBytes, + creationTime = expirationTimestampUs, + expirationTime = expirationTimestampUs, + scope = req.scope, + isRefreshable = req.refreshable + ) + if (!db.bearerTokenCreate(token)) + throw internalServerError("Failed at inserting new token in the database") + call.respond(TokenSuccessResponse( + access_token = Base32Crockford.encode(tokenBytes), + expiration = Timestamp( + t_s = expirationTimestampUs / 1000000L + ) + )) + return@post } post("/accounts") { // check if only admin. diff --git a/bank/src/main/kotlin/tech/libeufin/bank/bankTypes.kt b/bank/src/main/kotlin/tech/libeufin/bank/bankTypes.kt @@ -0,0 +1,329 @@ +/* + * 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 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) +} + + +// 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, + maybeCurrency: String? = null +) { + val currency: String = if (maybeCurrency == null) { + val internalCurrency = db.configGet("internal_currency") + ?: throw internalServerError("internal_currency not found in the config") + internalCurrency + } else maybeCurrency + + override fun equals(other: Any?): Boolean { + return other is TalerAmount && + other.value == this.value && + other.frac == this.frac && + other.currency == this.currency + } +} + +/** + * 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 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, + 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, // ISO20022 + val endToEndId: String, // ISO20022 + val paymentInformationId: String // 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, + // 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: ByteArray?, + 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 +) +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -0,0 +1,224 @@ +/* + * 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 net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford +import tech.libeufin.util.* +import java.lang.NumberFormatException + +// 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.", + 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. +} + + +/** + * Performs the HTTP basic authentication. Returns the + * authenticated customer on success, or null otherwise. + */ +fun doBasicAuth(encodedCredentials: String): Customer? { + val plainUserAndPass = String(base64ToBytes(encodedCredentials), Charsets.UTF_8) // :-separated + val userAndPassSplit = plainUserAndPass.split( + ":", + /** + * this parameter allows colons to occur in passwords. + * Without this, passwords that have colons would be split + * and become meaningless. + */ + 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." + ) + ) + val login = userAndPassSplit[0] + val plainPassword = userAndPassSplit[1] + val maybeCustomer = db.customerGetFromLogin(login) ?: return null + if (!CryptoUtil.checkpw(plainPassword, maybeCustomer.passwordHash)) return null + return maybeCustomer +} + +/** + * This function takes a prefixed Bearer token, removes the + * bearer-token:-prefix and returns it. Returns null, if the + * input is invalid. + */ +private fun splitBearerToken(tok: String): String? { + val tokenSplit = tok.split(":", limit = 2) + if (tokenSplit.size != 2) return null + if (tokenSplit[0] != "bearer-token") return null + return tokenSplit[1] +} + +/* Performs the bearer-token authentication. Returns the + * authenticated customer on success, null otherwise. */ +fun doTokenAuth( + token: String, + requiredScope: TokenScope, +): Customer? { + val bareToken = splitBearerToken(token) ?: throw badRequest( + "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 + ) + } + val maybeToken: BearerToken? = db.bearerTokenGet(tokenBytes) + if (maybeToken == null) { + logger.error("Auth token not found") + return null + } + if (maybeToken.expirationTime - getNowUs() < 0) { + logger.error("Auth token is expired") + return null + } + if (maybeToken.scope == TokenScope.readonly && requiredScope == TokenScope.readwrite) { + logger.error("Auth token has insufficient scope") + return null + } + if (!maybeToken.isRefreshable && requiredScope == TokenScope.refreshable) { + logger.error("Could not refresh unrefreshable token") + 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.", + )) +} + +fun forbidden(hint: String? = null, talerErrorCode: TalerErrorCode): LibeufinBankException = + LibeufinBankException( + httpStatus = HttpStatusCode.Forbidden, + talerError = TalerError( + code = talerErrorCode.code, + hint = hint + ) + ) + +fun unauthorized(hint: String? = null): 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 badRequest( + 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()}" + +/** + * This helper takes the serialized version of a Taler Amount + * type and parses it into Libeufin's internal representation. + * It returns a TalerAmount type, or throws a LibeufinBankException + * it the input is invalid. Such exception will be then caught by + * Ktor, transformed into the appropriate HTTP error type, and finally + * responded to the client. + */ +fun parseTalerAmount( + amount: String, + fracDigits: FracDigits = FracDigits.EIGHT +): TalerAmount { + val format = when (fracDigits) { + FracDigits.TWO -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?)?$" + FracDigits.EIGHT -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?)?$" + } + val match = Regex(format).find(amount) ?: throw LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, + hint = "Invalid amount: $amount" + )) + val _value = match.destructured.component2() + // Fraction is at most 8 digits, so it's always < than MAX_INT. + val fraction: Int = match.destructured.component3().run { + var frac = 0 + var power = 100000000 + if (this.isNotEmpty()) + // Skips the dot and processes the fractional chars. + this.substring(1).forEach { chr -> + power /= 10 + frac += power * chr.digitToInt() + } + return@run frac + } + val value: Long = try { + _value.toLong() + } catch (e: NumberFormatException) { + throw LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, + hint = "Invalid amount: ${amount}, could not extract the value part." + ) + ) + } + return TalerAmount( + value = value, + frac = fraction, + maybeCurrency = match.destructured.component1() + ) +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -20,9 +20,7 @@ import org.junit.Test import tech.libeufin.bank.* -import tech.libeufin.util.execCommand -import tech.libeufin.util.getNow -import tech.libeufin.util.toMicro +import tech.libeufin.util.getNowUs import java.util.Random import java.util.UUID @@ -68,8 +66,8 @@ class DatabaseTest { val token = BearerToken( bankCustomer = 1L, content = tokenBytes, - creationTime = getNow().toMicro(), // make .toMicro()? implicit? - expirationTime = getNow().plusDays(1).toMicro(), + creationTime = getNowUs(), // make .toMicro()? implicit? + expirationTime = getNowUs(), scope = TokenScope.readonly ) assert(db.bearerTokenGet(token.content) == null) diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -44,6 +44,6 @@ class JsonTest { "duration": {"d_us": 30} } """.trimIndent()) - assert(tokenReq.scope == TokenScope.readonly && tokenReq.duration.d_us == 30L) + assert(tokenReq.scope == TokenScope.readonly && tokenReq.duration?.d_us == 30L) } } \ No newline at end of file diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -1,14 +1,67 @@ +import io.ktor.auth.* import io.ktor.client.plugins.* import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* -import kotlinx.serialization.json.Json +import net.taler.wallet.crypto.Base32Crockford import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.execCommand +import tech.libeufin.util.getNowUs +import java.time.Duration +import kotlin.random.Random class LibeuFinApiTest { + private val customerFoo = Customer( + login = "foo", + passwordHash = CryptoUtil.hashpw("pw"), + name = "Foo", + phone = "+00", + email = "foo@b.ar", + cashoutPayto = "payto://external-IBAN", + cashoutCurrency = "KUDOS" + ) + // Checking the POST /token handling. + @Test + fun tokenTest() { + val db = initDb() + assert(db.customerCreate(customerFoo) != null) + testApplication { + application(webApp) + client.post("/accounts/foo/token") { + expectSuccess = true + contentType(ContentType.Application.Json) + basicAuth("foo", "pw") + setBody(""" + {"scope": "readonly"} + """.trimIndent()) + } + // foo tries on bar endpoint + val r = client.post("/accounts/bar/token") { + expectSuccess = false + basicAuth("foo", "pw") + } + assert(r.status == HttpStatusCode.Forbidden) + // Make ad-hoc token for foo. + val fooTok = ByteArray(32).apply { Random.nextBytes(this) } + assert(db.bearerTokenCreate(BearerToken( + content = fooTok, + bankCustomer = 1L, // only foo exists. + scope = TokenScope.readonly, + creationTime = getNowUs(), + isRefreshable = true, + expirationTime = getNowUs() + (Duration.ofHours(1).toMillis() * 1000) + ))) + // Testing the bearer-token:-scheme. + client.post("/accounts/foo/token") { + headers.set("Authorization", "Bearer bearer-token:${Base32Crockford.encode(fooTok)}") + contentType(ContentType.Application.Json) + setBody("{\"scope\": \"readonly\"}") + expectSuccess = true + } + } + } /** * Testing the account creation, its idempotency and * the restriction to admin to create accounts. diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -82,6 +82,7 @@ CREATE TABLE IF NOT EXISTS bearer_tokens ,creation_time INT8 ,expiration_time INT8 ,scope token_scope_enum + ,is_refreshable BOOLEAN ,bank_customer BIGINT NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE ); diff --git a/util/src/main/kotlin/LibeufinErrorCodes.kt b/util/src/main/kotlin/LibeufinErrorCodes.kt @@ -1,77 +0,0 @@ -/* - This file is part of GNU Taler - Copyright (C) 2012-2020 Taler Systems SA - - GNU Taler is free software: you can redistribute it and/or modify it - under the terms of the GNU Lesser General Public License as published - by the Free Software Foundation, either version 3 of the License, - or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but - WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. - - SPDX-License-Identifier: LGPL3.0-or-later - - Note: the LGPL does not apply to all components of GNU Taler, - but it does apply to this file. - */ - -package tech.libeufin.util - -enum class LibeufinErrorCode(val code: Int) { - - /** - * The error case didn't have a dedicate code. - */ - LIBEUFIN_EC_NONE(0), - - /** - * A payment being processed is neither CRDT not DBIT. This - * type of error should be detected _before_ storing the data - * into the database. - */ - LIBEUFIN_EC_INVALID_PAYMENT_DIRECTION(1), - - /** - * A bad piece of information made it to the database. For - * example, a transaction whose direction is neither CRDT nor DBIT - * was found in the database. - */ - LIBEUFIN_EC_INVALID_STATE(2), - - /** - * A bank's invariant is not holding anymore. For example, a customer's - * balance doesn't match the history of their bank account. - */ - LIBEUFIN_EC_INCONSISTENT_STATE(3), - - /** - * Access was forbidden due to wrong credentials. - */ - LIBEUFIN_EC_AUTHENTICATION_FAILED(4), - - /** - * A parameter in the request was malformed. - * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). - * (A value of 0 indicates that the error is generated client-side). - */ - LIBEUFIN_EC_GENERIC_PARAMETER_MALFORMED(5), - - /** - * Two different resources are NOT having the same currency. - */ - LIBEUFIN_EC_CURRENCY_INCONSISTENT(6), - - /** - * A request is using a unsupported currency. Usually returned - * along 400 Bad Request - */ - LIBEUFIN_EC_BAD_CURRENCY(7), - - LIBEUFIN_EC_TIMEOUT_EXPIRED(8) -} -\ No newline at end of file diff --git a/util/src/main/kotlin/TalerErrorCode.kt b/util/src/main/kotlin/TalerErrorCode.kt @@ -4295,6 +4295,4 @@ enum class TalerErrorCode(val code: Int) { * (A value of 0 indicates that the error is generated client-side). */ TALER_EC_END(9999), - - } diff --git a/util/src/main/kotlin/time.kt b/util/src/main/kotlin/time.kt @@ -20,10 +20,7 @@ package tech.libeufin.util import java.time.* -import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit -fun getNow(): ZonedDateTime { - return ZonedDateTime.now(ZoneId.systemDefault()) -} -fun ZonedDateTime.toMicro(): Long = this.nano / 1000L -\ No newline at end of file +fun getNowUs(): Long = ChronoUnit.MICROS.between(Instant.EPOCH, Instant.now()) +\ No newline at end of file