libeufin

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

commit 01e4e216098a7c650c45b28c6adf5a0f27f3a1ac
parent f9bac0535f6891afa21842a001075927d0402f0f
Author: ms <ms@taler.net>
Date:   Tue, 19 Oct 2021 09:20:04 +0200

implement 'demobank' resource

Diffstat:
Dsandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt | 47-----------------------------------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt | 67+++++++++++++++++--------------------------------------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt | 20++++++--------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt | 7+++++++
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 79++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mutil/src/main/kotlin/Config.kt | 8++++----
Mutil/src/main/kotlin/HTTP.kt | 54++++++++++++++++++++++++++++++++++++++++++++----------
7 files changed, 130 insertions(+), 152 deletions(-)

diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt @@ -1,47 +0,0 @@ -package tech.libeufin.sandbox - -import UtilError -import io.ktor.application.* -import io.ktor.http.* -import io.ktor.request.* -import jdk.jshell.execution.Util -import org.jetbrains.exposed.sql.transactions.transaction -import org.slf4j.LoggerFactory -import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.LibeufinErrorCode -import tech.libeufin.util.getHTTPBasicAuthCredentials - -private val logger = LoggerFactory.getLogger("tech.libeufin.util") -/** - * HTTP basic auth. Throws error if password is wrong, - * and makes sure that the user exists in the system. - * - * @return user entity - */ -fun authenticateRequest(request: ApplicationRequest): SandboxUserEntity { - return transaction { - val (username, password) = getHTTPBasicAuthCredentials(request) - val user = SandboxUserEntity.find { - SandboxUsersTable.username eq username - }.firstOrNull() - if (user == null) { - throw UtilError( - HttpStatusCode.Unauthorized, - "Unknown user '$username'", - LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED - ) - } - CryptoUtil.checkPwOrThrow(password, user.passwordHash) - user - } -} - -fun requireSuperuser(request: ApplicationRequest): SandboxUserEntity { - return transaction { - val user = authenticateRequest(request) - if (!user.superuser) { - throw SandboxError(HttpStatusCode.Forbidden, "must be superuser") - } - user - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt @@ -88,64 +88,33 @@ enum class KeyState { } // FIXME: This should be DemobankConfigTable! -object SandboxConfigsTable : LongIdTable() { +object DemobankConfigsTable : LongIdTable() { val currency = text("currency") val allowRegistrations = bool("allowRegistrations") val bankDebtLimit = integer("bankDebtLimit") val usersDebtLimit = integer("usersDebtLimit") - val hostname = text("hostname") + val name = text("hostname") } -class SandboxConfigEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<SandboxConfigEntity>(SandboxConfigsTable) - var currency by SandboxConfigsTable.currency - var allowRegistrations by SandboxConfigsTable.allowRegistrations - var bankDebtLimit by SandboxConfigsTable.bankDebtLimit - var usersDebtLimit by SandboxConfigsTable.usersDebtLimit - var hostname by SandboxConfigsTable.hostname +class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) { + companion object : LongEntityClass<DemobankConfigEntity>(DemobankConfigsTable) + var currency by DemobankConfigsTable.currency + var allowRegistrations by DemobankConfigsTable.allowRegistrations + var bankDebtLimit by DemobankConfigsTable.bankDebtLimit + var usersDebtLimit by DemobankConfigsTable.usersDebtLimit + var name by DemobankConfigsTable.name } /** - * Currently, this entity is never associated with a bank account, - * as those get only paired with Ebics subscribers! Eventually, a - * Ebics subscriber should map to a SandboxUserEntity that in turn - * will own bank accounts. - * - * FIXME: Do we really need normal users and superusers for the sandbox? - * => Nope, we don't even want user management for the sandbox! - * => This table must be killed, instead we just read the admin token via env variable - * and use a fixed "admin" user name. - */ -object SandboxUsersTable : LongIdTable() { - val username = text("username") - val passwordHash = text("password") - val superuser = bool("superuser") // admin - /** - * Some users may only have an administrative role in the system, - * therefore do not need a bank account. - */ - val bankAccount = reference("bankAccount", BankAccountsTable).nullable() -} - -class SandboxUserEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<SandboxUserEntity>(SandboxUsersTable) - var username by SandboxUsersTable.username - var passwordHash by SandboxUsersTable.passwordHash - var superuser by SandboxUsersTable.superuser - var bankAccount by BankAccountEntity optionalReferencedOn SandboxUsersTable.bankAccount -} - - -/** * Users who are allowed to log into the demo bank. * Created via the /demobanks/{demobankname}/register endpoint. */ -object DemobankUsersTable : LongIdTable() { - // FIXME: ... - // var isPublic = ... - // var demobankConfig (=> which demobank is this user part of) - // ... - // FIXME: Must have a mandatory foreign key reference into BankAccountTransactionsTable +object DemobankCustomersTable : LongIdTable() { + val isPublic = bool("isPublic").default(false) + val demobankConfig = reference("demobankConfig", DemobankConfigsTable) + val balance = text("balance") + val username = text("username") + val passwordHash = text("passwordHash") } @@ -479,8 +448,7 @@ fun dbDropTables(dbConnectionString: String) { BankAccountsTable, BankAccountReportsTable, BankAccountStatementsTable, - SandboxConfigsTable, - SandboxUsersTable, + DemobankConfigsTable, TalerWithdrawalsTable ) } @@ -491,8 +459,7 @@ fun dbCreateTables(dbConnectionString: String) { TransactionManager.manager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE transaction { SchemaUtils.create( - SandboxConfigsTable, - SandboxUsersTable, + DemobankConfigsTable, EbicsSubscribersTable, EbicsHostsTable, EbicsDownloadTransactionsTable, diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt @@ -87,24 +87,16 @@ fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity): BankAccount } } -/** - * Fetch a configuration for Sandbox, corresponding to the host that runs the service. - */ -fun getSandboxConfig(hostname: String?): SandboxConfigEntity { - var ret: SandboxConfigEntity? = transaction { - if (hostname == null) { - SandboxConfigEntity.all().firstOrNull() +fun getSandboxConfig(name: String?): DemobankConfigEntity? { + return transaction { + if (name == null) { + DemobankConfigEntity.all().firstOrNull() } else { - SandboxConfigEntity.find { - SandboxConfigsTable.hostname eq hostname + DemobankConfigEntity.find { + DemobankConfigsTable.name eq name }.firstOrNull() } } - if (ret == null) throw SandboxError( - HttpStatusCode.InternalServerError, - "Serving from a non configured host" - ) - return ret } fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: String): EbicsSubscriberEntity { diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt @@ -22,6 +22,13 @@ package tech.libeufin.sandbox import tech.libeufin.util.PaymentInfo import tech.libeufin.util.RawPayment +data class Demobank( + val currency: String, + val name: String, + val userDebtLimit: Int, + val bankDebtLimit: Int, + val allowRegistrations: Boolean +) /** * Used to show the list of Ebics hosts that exist * in the system. diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -104,8 +104,8 @@ class Config : CliktCommand("Insert one configuration into the database") { } } - private val hostnameOption by argument( - "HOSTNAME", help = "hostname that serves this configuration" + private val nameOption by argument( + "NAME", help = "Name of this configuration" ) private val currencyOption by option("--currency").default("EUR") private val bankDebtLimitOption by option("--bank-debt-limit").int().default(1000000) @@ -120,12 +120,19 @@ class Config : CliktCommand("Insert one configuration into the database") { execThrowableOrTerminate { dbCreateTables(dbConnString) transaction { - SandboxConfigEntity.new { + val checkExist = DemobankConfigEntity.find { + DemobankConfigsTable.name eq nameOption + }.firstOrNull() + if (checkExist != null) { + println("Error, demobank ${nameOption} exists already, not overriding it.") + exitProcess(1) + } + DemobankConfigEntity.new { currency = currencyOption bankDebtLimit = bankDebtLimitOption usersDebtLimit = usersDebtLimitOption allowRegistrations = allowRegistrationsOption - hostname = hostnameOption + name = nameOption } } } @@ -254,6 +261,11 @@ class Serve : CliktCommand("Run sandbox HTTP server") { override fun run() { WITH_AUTH = auth setLogLevel(logLevel) + if (WITH_AUTH && adminPassword == null) { + println("Error: auth is enabled, but env LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not." + + " (Option --no-auth exists for tests)") + exitProcess(1) + } execThrowableOrTerminate { dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) } if (withUnixSocket != null) { startServer( @@ -266,6 +278,15 @@ class Serve : CliktCommand("Run sandbox HTTP server") { } } +private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank { + return Demobank( + currency = fromDb.currency, + userDebtLimit = fromDb.usersDebtLimit, + bankDebtLimit = fromDb.bankDebtLimit, + allowRegistrations = fromDb.allowRegistrations, + name = fromDb.name + ) +} fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?): EbicsSubscriberEntity? { return if (systemID == null) { EbicsSubscriberEntity.find { @@ -460,7 +481,9 @@ val sandboxApp: Application.() -> Unit = { if(adminPassword == null) { throw internalServerError( "Sandbox has no admin password defined." + - " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment." + " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment, " + + "or launch with --no-auth." + ) } ac.attributes.put( @@ -1018,33 +1041,37 @@ val sandboxApp: Application.() -> Unit = { // debt limit and possibly other configuration // (could also be a CLI command for now) post("/demobanks") { - + throw NotImplementedError("Only available in the CLI.") } - // List configured demobanks get("/demobanks") { - - } - - delete("/demobank/{demobankid") { - + expectAdmin(call.request.basicAuth()) + val ret = object { val demoBanks = mutableListOf<Demobank>() } + transaction { + DemobankConfigEntity.all().forEach { + ret.demoBanks.add(getJsonFromDemobankConfig(it)) + } + } + call.respond(ret) + return@get } - get("/demobank/{demobankid") { - + get("/demobanks/{demobankid}") { + expectAdmin(call.request.basicAuth()) + val demobankId = call.getUriComponent("demobankid") + val ret: DemobankConfigEntity = transaction { + DemobankConfigEntity.find { + DemobankConfigsTable.name eq demobankId + }.firstOrNull() + } ?: throw notFound("Demobank ${demobankId} not found") + call.respond(getJsonFromDemobankConfig(ret)) + return@get } - route("/demobank/{demobankid}") { - // Note: Unlike the old pybank, the sandbox does *not* actually expose the - // taler wire gateway API, because the exchange uses the nexus. - - // Endpoint(s) for making arbitrary payments in the sandbox for integration tests - // FIXME: Do we actually need this, or can we just use the sandbox admin APIs? - route("/testing-api") { - - } + route("/demobanks/{demobankid}") { route("/access-api") { + get("/accounts/{account_name}") { // Authenticated. Accesses basic information (balance) // about an account. (see docs) @@ -1068,10 +1095,8 @@ val sandboxApp: Application.() -> Unit = { // Get transaction history of a public account } - post("/testing/register") { - // Register a new account. - // No authentication is required to register a new user. - // FIXME: Should probably not use "testing" as the prefix, since it's used "in production" in the demobank SPA + post("/register") { + } } diff --git a/util/src/main/kotlin/Config.kt b/util/src/main/kotlin/Config.kt @@ -92,10 +92,10 @@ fun getValueFromEnv(varName: String): String? { fun getDbConnFromEnv(varName: String): String { val dbConnStr = System.getenv(varName) if (dbConnStr.isNullOrBlank() or dbConnStr.isNullOrEmpty()) { - printLnErr("DB connection string not found/valid in the env variable $varName.") - printLnErr("The following two examples are valid connection strings:") - printLnErr("jdbc:sqlite:/tmp/libeufindb.sqlite3") - printLnErr("jdbc:postgresql://localhost:5432/libeufindb?user=Foo&password=secret") + printLnErr("\nError: DB connection string undefined or invalid in the env variable $varName.") + printLnErr("\nThe following two examples are valid connection strings:") + printLnErr("\njdbc:sqlite:/tmp/libeufindb.sqlite3") + printLnErr("jdbc:postgresql://localhost:5432/libeufindb?user=Foo&password=secret\n") exitProcess(1) } return dbConnStr diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -16,7 +16,11 @@ private fun unauthorized(msg: String): UtilError { ) } - +fun notFound(msg: String): UtilError { + return UtilError( + HttpStatusCode.NotFound, msg, LibeufinErrorCode.LIBEUFIN_EC_NONE + ) +} /** * Returns the token (including the 'secret-token:' prefix) @@ -48,6 +52,14 @@ fun internalServerError( ) } +fun badRequest(msg: String): UtilError { + return UtilError( + HttpStatusCode.BadRequest, + msg, + ec = LibeufinErrorCode.LIBEUFIN_EC_NONE + ) +} + /** * Get the base URL of a request; handles proxied case. */ @@ -81,18 +93,25 @@ fun ApplicationRequest.getBaseUrl(): String { } /** - * Authenticate the HTTP request with a given token. This one - * is expected to comply with the RFC 8959 format; the function - * throws an exception when the authentication fails - * - * @param tokenEnv is the authorization token that was found in the - * environment. + * Get the URI (path's) component or throw Internal server error. + * @param component the name of the URI component to return. + */ +fun ApplicationCall.getUriComponent(name: String): String { + val ret: String? = this.parameters[name] + if (ret == null) throw internalServerError("Component $name not found in URI") + return ret +} +/** + * Return: + * - null if the authentication is disabled (during tests, for example) + * - the name of the authenticated user + * - throw exception when the authentication fails */ -fun ApplicationRequest.basicAuth() { +fun ApplicationRequest.basicAuth(): String? { val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY) if (!withAuth) { logger.info("Authentication is disabled - assuming tests currently running.") - return + return null } val credentials = getHTTPBasicAuthCredentials(this) if (credentials.first == "admin") { @@ -101,7 +120,7 @@ fun ApplicationRequest.basicAuth() { if (credentials.second != adminPassword) throw unauthorized( "Admin authentication failed" ) - return + return credentials.first } throw unauthorized("Demobank customers not implemented yet!") /** @@ -109,6 +128,20 @@ fun ApplicationRequest.basicAuth() { */ } +/** + * Throw "unauthorized" if the request is not + * authenticated by "admin", silently return otherwise. + * + * @param username who made the request. + */ +fun expectAdmin(username: String?) { + if (username == null) { + logger.info("Skipping 'admin' authentication for tests.") + return + } + if (username != "admin") throw unauthorized("Only admin allowed: $username is not.") +} + fun getHTTPBasicAuthCredentials(request: ApplicationRequest): Pair<String, String> { val authHeader = getAuthorizationHeader(request) return extractUserAndPassword(authHeader) @@ -119,6 +152,7 @@ fun getHTTPBasicAuthCredentials(request: ApplicationRequest): Pair<String, Strin */ fun getAuthorizationHeader(request: ApplicationRequest): String { val authorization = request.headers["Authorization"] + logger.debug("Found Authorization header: $authorization") return authorization ?: throw UtilError( HttpStatusCode.BadRequest, "Authorization header not found", LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED