[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[libeufin] branch master updated: Implementing token authentication.
From: |
gnunet |
Subject: |
[libeufin] branch master updated: Implementing token authentication. |
Date: |
Sun, 17 Sep 2023 10:09:21 +0200 |
This is an automated email from the git hooks/post-receive script.
ms pushed a commit to branch master
in repository libeufin.
The following commit(s) were added to refs/heads/master by this push:
new 365df3c3 Implementing token authentication.
365df3c3 is described below
commit 365df3c31e524a542eab8551d3384fbd32654ec9
Author: MS <ms@taler.net>
AuthorDate: Sun Sep 17 10:08:48 2023 +0200
Implementing token authentication.
---
.../src/main/kotlin/tech/libeufin/bank/Database.kt | 152 +---------
bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 137 +++++----
.../main/kotlin/tech/libeufin/bank/bankTypes.kt | 329 +++++++++++++++++++++
.../tech/libeufin/bank/{Helpers.kt => helpers.kt} | 79 ++++-
bank/src/test/kotlin/DatabaseTest.kt | 8 +-
bank/src/test/kotlin/JsonTest.kt | 2 +-
bank/src/test/kotlin/LibeuFinApiTest.kt | 57 +++-
database-versioning/libeufin-bank-0001.sql | 1 +
util/src/main/kotlin/LibeufinErrorCodes.kt | 77 -----
util/src/main/kotlin/TalerErrorCode.kt | 2 -
util/src/main/kotlin/time.kt | 7 +-
11 files changed, 556 insertions(+), 295 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
index 0bfd88ad..73194c36 100644
--- 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/Main.kt
b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 12dc927a..2e30038d 100644
--- 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
new file mode 100644
index 00000000..25e7edca
--- /dev/null
+++ 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
similarity index 67%
rename from bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt
rename to bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index 41f4d4c1..28c58fe5 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -20,11 +20,29 @@
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
-// HELPERS.
+// 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
@@ -56,15 +74,53 @@ fun doBasicAuth(encodedCredentials: String): Customer? {
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, // readonly or readwrite
+ requiredScope: TokenScope,
): 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?
+ 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(
@@ -75,6 +131,15 @@ fun doTokenAuth(
))
}
+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,
@@ -83,7 +148,7 @@ fun unauthorized(hint: String? = null):
LibeufinBankException =
hint = hint
)
)
-fun internalServerError(hint: String): LibeufinBankException =
+fun internalServerError(hint: String?): LibeufinBankException =
LibeufinBankException(
httpStatus = HttpStatusCode.InternalServerError,
talerError = TalerError(
@@ -96,7 +161,7 @@ fun badRequest(
talerErrorCode: TalerErrorCode =
TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID
): LibeufinBankException =
LibeufinBankException(
- httpStatus = HttpStatusCode.InternalServerError,
+ httpStatus = HttpStatusCode.BadRequest,
talerError = TalerError(
code = talerErrorCode.code,
hint = hint
diff --git a/bank/src/test/kotlin/DatabaseTest.kt
b/bank/src/test/kotlin/DatabaseTest.kt
index 6961a9b5..290d0d19 100644
--- 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
index 384334a6..63eff6f9 100644
--- 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
index 48ca69a6..4ee9a803 100644
--- 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
index 9daaa7b0..0037ab14 100644
--- 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
deleted file mode 100644
index e60b4015..00000000
--- a/util/src/main/kotlin/LibeufinErrorCodes.kt
+++ /dev/null
@@ -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
index 7a509405..bf46a498 100644
--- 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
index 3589dec2..687a97f7 100644
--- 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
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [libeufin] branch master updated: Implementing token authentication.,
gnunet <=