libeufin

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

commit 42c62f26b4fe0e5cc2fc3f29f8b79f25b2c49ee0
parent 42f6a845dd0769deae0fb9bb43f505e369ef9fcd
Author: ms <ms@taler.net>
Date:   Fri, 17 Sep 2021 15:46:37 +0200

Provide authentication to Sandbox

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/Auth.kt | 44+++++++++-----------------------------------
Asandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt | 45+++++++++++++++++++++++++++++++++++++++++++++
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mutil/src/main/kotlin/CryptoUtil.kt | 14++++++++++++++
Mutil/src/main/kotlin/EbicsOrderUtil.kt | 2+-
Mutil/src/main/kotlin/Errors.kt | 7++++++-
Autil/src/main/kotlin/HTTP.kt | 43+++++++++++++++++++++++++++++++++++++++++++
Mutil/src/main/kotlin/LibeufinErrorCodes.kt | 14+++++++++++++-
Mutil/src/main/kotlin/XMLUtil.kt | 3+--
Mutil/src/main/kotlin/strings.kt | 6+++---
10 files changed, 198 insertions(+), 43 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Auth.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Auth.kt @@ -1,5 +1,6 @@ package tech.libeufin.nexus +import UtilError import io.ktor.application.* import io.ktor.http.* import io.ktor.request.* @@ -7,54 +8,27 @@ import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.transactions.transaction import tech.libeufin.nexus.server.Permission import tech.libeufin.nexus.server.PermissionQuery -import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.base64ToBytes -import tech.libeufin.util.constructXml - +import tech.libeufin.util.* /** - * This helper function parses a Authorization:-header line, decode the credentials - * and returns a pair made of username and hashed (sha256) password. The hashed value - * will then be compared with the one kept into the database. - */ -private fun extractUserAndPassword(authorizationHeader: String): Pair<String, String> { - logger.debug("Authenticating: $authorizationHeader") - val (username, password) = try { - val split = authorizationHeader.split(" ") - val plainUserAndPass = String(base64ToBytes(split[1]), Charsets.UTF_8) - plainUserAndPass.split(":") - } catch (e: java.lang.Exception) { - throw NexusError( - HttpStatusCode.BadRequest, - "invalid Authorization:-header received" - ) - } - return Pair(username, password) -} - - -/** - * Test HTTP basic auth. Throws error if password is wrong, + * 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): NexusUserEntity { return transaction { - val authorization = request.headers["Authorization"] - val headerLine = if (authorization == null) throw NexusError( - HttpStatusCode.BadRequest, "Authorization header not found" - ) else authorization - val (username, password) = extractUserAndPassword(headerLine) + val (username, password) = getHTTPBasicAuthCredentials(request) val user = NexusUserEntity.find { NexusUsersTable.username eq username }.firstOrNull() if (user == null) { - throw NexusError(HttpStatusCode.Unauthorized, "Unknown user '$username'") - } - if (!CryptoUtil.checkpw(password, user.passwordHash)) { - throw NexusError(HttpStatusCode.Forbidden, "Wrong password") + throw UtilError(HttpStatusCode.Unauthorized, + "Unknown user '$username'", + LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED + ) } + CryptoUtil.checkPwOrThrow(password, username) user } } diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt @@ -0,0 +1,45 @@ +package tech.libeufin.sandbox + +import UtilError +import io.ktor.http.* +import io.ktor.request.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.util.CryptoUtil +import tech.libeufin.util.LibeufinErrorCode +import tech.libeufin.util.getHTTPBasicAuthCredentials + + +/** + * 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, username) + 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/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -57,6 +57,7 @@ import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import org.jetbrains.exposed.sql.statements.api.ExposedBlob import java.time.Instant import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.ProgramResult import com.github.ajalt.clikt.core.context import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.core.subcommands @@ -71,6 +72,7 @@ import io.ktor.http.* import io.ktor.http.content.* import io.ktor.request.* import io.ktor.util.date.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import tech.libeufin.util.* import tech.libeufin.util.ebics_h004.EbicsResponse import tech.libeufin.util.ebics_h004.EbicsTypes @@ -92,6 +94,33 @@ data class SandboxError( data class SandboxErrorJson(val error: SandboxErrorDetailJson) data class SandboxErrorDetailJson(val type: String, val description: String) +class Superuser : CliktCommand("Add superuser or change pw") { + private val username by argument() + private val password by option().prompt(requireConfirmation = true, hideInput = true) + override fun run() { + execThrowableOrTerminate { + dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) + } + transaction { + val hashedPw = CryptoUtil.hashpw(password) + val user = SandboxUserEntity.find { SandboxUsersTable.username eq username }.firstOrNull() + if (user == null) { + SandboxUserEntity.new { + this.username = this@Superuser.username + this.passwordHash = hashedPw + this.superuser = true + } + } else { + if (!user.superuser) { + println("Can only change password for superuser with this command.") + throw ProgramResult(1) + } + user.passwordHash = hashedPw + } + } + } +} + class Config : CliktCommand("Insert one configuration into the database") { init { context { @@ -358,6 +387,7 @@ class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnE fun main(args: Array<String>) { SandboxCommand().subcommands( + Superuser(), Serve(), ResetTables(), Config(), @@ -496,6 +526,9 @@ fun serverMain(dbName: String, port: Int) { return@get } */ + /* + FIXME: not implemented. + post("/register") { // how to read form-POSTed values? val username = "fixme" @@ -531,6 +564,11 @@ fun serverMain(dbName: String, port: Int) { return@post } + */ + + /* + FIXME: will likely be replaced by the Single Page Application + get("/jinja-test") { val template = Resources.toString( Resources.getResource("templates/hello.html"), @@ -542,6 +580,12 @@ fun serverMain(dbName: String, port: Int) { return@get } + */ + + /* + + FIXME: not used + authenticate("auth-form") { get("/profile") { val userSession = call.principal<UserIdPrincipal>() @@ -551,12 +595,18 @@ fun serverMain(dbName: String, port: Int) { } } + */ + + /* + FIXME: not used + static("/static") { /** * Here Sandbox will serve the CSS files. */ resources("static") } + */ get("/") { call.respondText("Hello, this is Sandbox\n", ContentType.Text.Plain) @@ -574,6 +624,7 @@ fun serverMain(dbName: String, port: Int) { * requesting account. */ post("/admin/payments/camt") { + requireSuperuser(call.request) val body = call.receiveJson<CamtParams>() val bankaccount = getAccountFromLabel(body.bankaccount) if(body.type != 53) throw SandboxError( @@ -595,6 +646,7 @@ fun serverMain(dbName: String, port: Int) { } post("/admin/bank-accounts/{label}") { + requireSuperuser(call.request) val body = call.receiveJson<BankAccountInfo>() transaction { BankAccountEntity.new { @@ -610,6 +662,7 @@ fun serverMain(dbName: String, port: Int) { } get("/admin/bank-accounts/{label}") { + requireSuperuser(call.request) val label = ensureNonNull(call.parameters["label"]) val ret = transaction { val bankAccount = BankAccountEntity.find { @@ -632,6 +685,7 @@ fun serverMain(dbName: String, port: Int) { } post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { + requireSuperuser(call.request) val body = call.receiveJson<IncomingPaymentInfo>() // FIXME: generate nicer UUID! val accountLabel = ensureNonNull(call.parameters["label"]) @@ -674,6 +728,7 @@ fun serverMain(dbName: String, port: Int) { * Associates a new bank account with an existing Ebics subscriber. */ post("/admin/ebics/bank-accounts") { + requireSuperuser(call.request) val body = call.receiveJson<BankAccountRequest>() if (!validateBic(body.bic)) { throw SandboxError(HttpStatusCode.BadRequest, "invalid BIC (${body.bic})") @@ -703,6 +758,7 @@ fun serverMain(dbName: String, port: Int) { return@post } get("/admin/bank-accounts") { + requireSuperuser(call.request) val accounts = mutableListOf<BankAccountInfo>() transaction { BankAccountEntity.all().forEach { @@ -720,6 +776,7 @@ fun serverMain(dbName: String, port: Int) { call.respond(accounts) } get("/admin/bank-accounts/{label}/transactions") { + requireSuperuser(call.request) val ret = AccountTransactions() transaction { val accountLabel = ensureNonNull(call.parameters["label"]) @@ -758,6 +815,7 @@ fun serverMain(dbName: String, port: Int) { call.respond(ret) } post("/admin/bank-accounts/{label}/generate-transactions") { + requireSuperuser(call.request) transaction { val accountLabel = ensureNonNull(call.parameters["label"]) val account = getBankAccountFromLabel(accountLabel) @@ -809,6 +867,7 @@ fun serverMain(dbName: String, port: Int) { * Creates a new Ebics subscriber. */ post("/admin/ebics/subscribers") { + requireSuperuser(call.request) val body = call.receiveJson<EbicsSubscriberElement>() transaction { EbicsSubscriberEntity.new { @@ -830,6 +889,7 @@ fun serverMain(dbName: String, port: Int) { * Shows all the Ebics subscribers' details. */ get("/admin/ebics/subscribers") { + requireSuperuser(call.request) val ret = AdminGetSubscribers() transaction { EbicsSubscriberEntity.all().forEach { @@ -846,6 +906,7 @@ fun serverMain(dbName: String, port: Int) { return@get } post("/admin/ebics/hosts/{hostID}/rotate-keys") { + requireSuperuser(call.request) val hostID: String = call.parameters["hostID"] ?: throw SandboxError( HttpStatusCode.BadRequest, "host ID missing in URL" ) @@ -874,6 +935,7 @@ fun serverMain(dbName: String, port: Int) { * Creates a new EBICS host. */ post("/admin/ebics/hosts") { + requireSuperuser(call.request) val req = call.receiveJson<EbicsHostCreateRequest>() val pairA = CryptoUtil.generateRsaKeyPair(2048) val pairB = CryptoUtil.generateRsaKeyPair(2048) @@ -899,6 +961,7 @@ fun serverMain(dbName: String, port: Int) { * Show the names of all the Ebics hosts */ get("/admin/ebics/hosts") { + requireSuperuser(call.request) val ebicsHosts = transaction { EbicsHostEntity.all().map { it.hostId } } diff --git a/util/src/main/kotlin/CryptoUtil.kt b/util/src/main/kotlin/CryptoUtil.kt @@ -19,6 +19,8 @@ package tech.libeufin.util +import UtilError +import io.ktor.http.* import net.taler.wallet.crypto.Base32Crockford import org.bouncycastle.jce.provider.BouncyCastleProvider import java.io.ByteArrayOutputStream @@ -308,6 +310,18 @@ object CryptoUtil { return "sha256-salted\$$salt\$$pwh" } + /** + * Throws error when credentials don't match. Only returns in case of success. + */ + fun checkPwOrThrow(pw: String, storedPwHash: String): Boolean { + if(!this.checkpw(pw, storedPwHash)) throw UtilError( + HttpStatusCode.Forbidden, + "Credentials did not match", + LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED + ) + return true + } + fun checkpw(pw: String, storedPwHash: String): Boolean { val components = storedPwHash.split('$') if (components.size < 2) { diff --git a/util/src/main/kotlin/EbicsOrderUtil.kt b/util/src/main/kotlin/EbicsOrderUtil.kt @@ -55,7 +55,7 @@ object EbicsOrderUtil { val rng = SecureRandom() val res = ByteArray(16) rng.nextBytes(res) - return res.toHexString().toUpperCase() + return res.toHexString().uppercase() } /** diff --git a/util/src/main/kotlin/Errors.kt b/util/src/main/kotlin/Errors.kt @@ -1,6 +1,9 @@ import io.ktor.http.* +import tech.libeufin.util.LibeufinErrorCode import tech.libeufin.util.TalerErrorCode import kotlin.system.exitProcess +import org.slf4j.Logger +import org.slf4j.LoggerFactory /* * This file is part of LibEuFin. @@ -21,10 +24,12 @@ import kotlin.system.exitProcess * <http://www.gnu.org/licenses/> */ +val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util") + data class UtilError( val statusCode: HttpStatusCode, val reason: String, - val ec: TalerErrorCode? + val ec: LibeufinErrorCode? ) : Exception("$reason (HTTP status $statusCode)") diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -0,0 +1,43 @@ +package tech.libeufin.util + +import UtilError +import io.ktor.http.* +import io.ktor.request.* +import logger + +fun getHTTPBasicAuthCredentials(request: ApplicationRequest): Pair<String, String> { + val authHeader = getAuthorizationHeader(request) + return extractUserAndPassword(authHeader) +} + +/** + * Extracts the Authorization:-header line and throws error if not found. + */ +fun getAuthorizationHeader(request: ApplicationRequest): String { + val authorization = request.headers["Authorization"] + return authorization ?: throw UtilError( + HttpStatusCode.BadRequest, "Authorization header not found", + LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED + ) +} + +/** + * This helper function parses a Authorization:-header line, decode the credentials + * and returns a pair made of username and hashed (sha256) password. The hashed value + * will then be compared with the one kept into the database. + */ +fun extractUserAndPassword(authorizationHeader: String): Pair<String, String> { + logger.debug("Authenticating: $authorizationHeader") + val (username, password) = try { + val split = authorizationHeader.split(" ") + val plainUserAndPass = String(base64ToBytes(split[1]), Charsets.UTF_8) + plainUserAndPass.split(":") + } catch (e: java.lang.Exception) { + throw UtilError( + HttpStatusCode.BadRequest, + "invalid Authorization:-header received", + LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED + ) + } + return Pair(username, password) +} diff --git a/util/src/main/kotlin/LibeufinErrorCodes.kt b/util/src/main/kotlin/LibeufinErrorCodes.kt @@ -48,5 +48,17 @@ enum class LibeufinErrorCode(val code: Int) { * 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) + LIBEUFIN_EC_INCONSISTENT_STATE(3), + + /** + * An 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), } \ No newline at end of file diff --git a/util/src/main/kotlin/XMLUtil.kt b/util/src/main/kotlin/XMLUtil.kt @@ -61,8 +61,7 @@ import javax.xml.validation.Validator import javax.xml.xpath.XPath import javax.xml.xpath.XPathConstants import javax.xml.xpath.XPathFactory - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util") +import logger class DefaultNamespaces : NamespacePrefixMapper() { override fun getPreferredPrefix(namespaceUri: String?, suggestion: String?, requirePrefix: Boolean): String? { diff --git a/util/src/main/kotlin/strings.kt b/util/src/main/kotlin/strings.kt @@ -96,7 +96,7 @@ fun chunkString(input: String): String { } ret.append(input[i]) } - return ret.toString().toUpperCase() + return ret.toString().uppercase() } data class AmountWithCurrency( @@ -109,7 +109,7 @@ fun parseDecimal(decimalStr: String): BigDecimal { throw UtilError( HttpStatusCode.BadRequest, "Bad string amount given: $decimalStr", - TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MALFORMED + LibeufinErrorCode.LIBEUFIN_EC_GENERIC_PARAMETER_MALFORMED ) return try { BigDecimal(decimalStr) @@ -117,7 +117,7 @@ fun parseDecimal(decimalStr: String): BigDecimal { throw UtilError( HttpStatusCode.BadRequest, "Bad string amount given: $decimalStr", - TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MALFORMED + LibeufinErrorCode.LIBEUFIN_EC_GENERIC_PARAMETER_MALFORMED ) } }