libeufin

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

commit 48d69b46a9a7d83af995e6e73f51f1ad86dd33d9
parent 44247859381f5173f209d884dae24a2b7d6cdd69
Author: ms <ms@taler.net>
Date:   Tue, 12 Oct 2021 10:24:57 +0200

Implment token-based authentication.

Diffstat:
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt | 6++++--
Msandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt | 1-
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 67++++++++++++++++---------------------------------------------------
Mutil/src/main/kotlin/HTTP.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Autil/src/test/kotlin/AuthTokenTest.kt | 35+++++++++++++++++++++++++++++++++++
5 files changed, 102 insertions(+), 54 deletions(-)

diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Auth.kt @@ -1,15 +1,17 @@ package tech.libeufin.sandbox import UtilError +import io.ktor.application.* import io.ktor.http.* import io.ktor.request.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +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. diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt @@ -502,7 +502,6 @@ fun getLastBalance(bankAccount: BankAccountEntity): BigDecimal { private fun constructCamtResponse( type: Int, subscriber: EbicsSubscriberEntity, - // fixes #6243 dateRange: Pair<Long, Long>?): List<String> { if (type != 53 && type != 52) throw EbicsUnsupportedOrderType() diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -97,6 +97,8 @@ private val baseUrl = URL( getValueFromEnv("LIBEUFIN_SANDBOX_BASE_URL") ?: throw Exception( "env LIBEUFIN_SANDBOX_BASE_URL is not defined") ) +// when null, privileged operations turn impossible +private val sandboxToken: String? = getValueFromEnv("LIBEUFIN_SANDBOX_TOKEN") data class SandboxError( val statusCode: HttpStatusCode, @@ -107,42 +109,6 @@ 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)) - } - try { - requireValidResourceName(username) - } catch (e: UtilError) { - println(e) // Gives instructions about the allowed format. - exitProcess(1) - } - transaction { - val user = SandboxUserEntity.find { - SandboxUsersTable.username eq username - }.firstOrNull() - - val hashedPw = CryptoUtil.hashpw(password) - 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 { @@ -371,7 +337,6 @@ class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnE fun main(args: Array<String>) { SandboxCommand().subcommands( - Superuser(), Serve(), ResetTables(), Config(), @@ -513,7 +478,7 @@ val sandboxApp: Application.() -> Unit = { * requesting account. */ post("/admin/payments/camt") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val body = call.receiveJson<CamtParams>() val bankaccount = getAccountFromLabel(body.bankaccount) if (body.type != 53) throw SandboxError( @@ -535,7 +500,7 @@ val sandboxApp: Application.() -> Unit = { } post("/admin/bank-accounts/{label}") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val body = call.receiveJson<BankAccountInfo>() transaction { tech.libeufin.sandbox.BankAccountEntity.new { @@ -551,7 +516,7 @@ val sandboxApp: Application.() -> Unit = { } get("/admin/bank-accounts/{label}") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val label = ensureNonNull(call.parameters["label"]) val ret = transaction { val bankAccount = tech.libeufin.sandbox.BankAccountEntity.find { @@ -574,7 +539,7 @@ val sandboxApp: Application.() -> Unit = { } post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val body = call.receiveJson<IncomingPaymentInfo>() // FIXME: generate nicer UUID! val accountLabel = ensureNonNull(call.parameters["label"]) @@ -617,7 +582,7 @@ val sandboxApp: Application.() -> Unit = { * Associates a new bank account with an existing Ebics subscriber. */ post("/admin/ebics/bank-accounts") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val body = call.receiveJson<BankAccountRequest>() if (!validateBic(body.bic)) { throw SandboxError(io.ktor.http.HttpStatusCode.BadRequest, "invalid BIC (${body.bic})") @@ -647,7 +612,7 @@ val sandboxApp: Application.() -> Unit = { return@post } get("/admin/bank-accounts") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val accounts = mutableListOf<BankAccountInfo>() transaction { tech.libeufin.sandbox.BankAccountEntity.all().forEach { @@ -665,7 +630,7 @@ val sandboxApp: Application.() -> Unit = { call.respond(accounts) } get("/admin/bank-accounts/{label}/transactions") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val ret = AccountTransactions() transaction { val accountLabel = ensureNonNull(call.parameters["label"]) @@ -704,7 +669,7 @@ val sandboxApp: Application.() -> Unit = { call.respond(ret) } post("/admin/bank-accounts/{label}/generate-transactions") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) transaction { val accountLabel = ensureNonNull(call.parameters["label"]) val account = getBankAccountFromLabel(accountLabel) @@ -756,7 +721,7 @@ val sandboxApp: Application.() -> Unit = { * Creates a new Ebics subscriber. */ post("/admin/ebics/subscribers") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val body = call.receiveJson<EbicsSubscriberElement>() transaction { tech.libeufin.sandbox.EbicsSubscriberEntity.new { @@ -778,7 +743,7 @@ val sandboxApp: Application.() -> Unit = { * Shows all the Ebics subscribers' details. */ get("/admin/ebics/subscribers") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val ret = AdminGetSubscribers() transaction { tech.libeufin.sandbox.EbicsSubscriberEntity.all().forEach { @@ -795,7 +760,7 @@ val sandboxApp: Application.() -> Unit = { return@get } post("/admin/ebics/hosts/{hostID}/rotate-keys") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val hostID: String = call.parameters["hostID"] ?: throw SandboxError( io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in URL" ) @@ -824,7 +789,7 @@ val sandboxApp: Application.() -> Unit = { * Creates a new EBICS host. */ post("/admin/ebics/hosts") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val req = call.receiveJson<EbicsHostCreateRequest>() val pairA = tech.libeufin.util.CryptoUtil.generateRsaKeyPair(2048) val pairB = tech.libeufin.util.CryptoUtil.generateRsaKeyPair(2048) @@ -850,7 +815,7 @@ val sandboxApp: Application.() -> Unit = { * Show the names of all the Ebics hosts */ get("/admin/ebics/hosts") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) val ebicsHosts = transaction { tech.libeufin.sandbox.EbicsHostEntity.all().map { it.hostId } } @@ -888,7 +853,7 @@ val sandboxApp: Application.() -> Unit = { * the default exchange, from a designated/constant customer. */ get("/taler") { - requireSuperuser(call.request) + call.request.authWithToken(sandboxToken) SandboxAssert( currencyEnv != null, "Currency not found. Logs should have warned" diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -4,6 +4,53 @@ import UtilError import io.ktor.http.* import io.ktor.request.* import logger +import java.net.URLDecoder + +private fun unauthorized(msg: String): UtilError { + return UtilError( + HttpStatusCode.Unauthorized, + msg, + LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED + ) +} + +/** + * Returns the token (including the 'secret-token:' prefix) + * from a Authorization header. Throws exception on malformations + * Note, the token gets URL-decoded before being returned. + */ +fun extractToken(authHeader: String): String { + val headerSplit = authHeader.split(" ", limit = 2) + if (headerSplit.elementAtOrNull(0) != "Bearer") throw unauthorized("Authorization header does not start with 'Bearer'") + val token = headerSplit.elementAtOrNull(1) + if (token == null) throw unauthorized("Authorization header did not have the token") + val tokenSplit = token.split(":", limit = 2) + if (tokenSplit.elementAtOrNull(0) != "secret-token") + throw unauthorized("Token lacks the 'secret-token:' prefix, see RFC 8959") + val maybeToken = tokenSplit.elementAtOrNull(1) + if(maybeToken == null || maybeToken == "") + throw unauthorized("Actual token missing after the 'secret-token:' prefix") + return "${tokenSplit[0]}:${URLDecoder.decode(tokenSplit[1], Charsets.UTF_8)}" +} + +/** + * 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. + */ +fun ApplicationRequest.authWithToken(tokenEnv: String?) { + if (tokenEnv == null) { + logger.info("Authenticating operation without any env token!") + throw unauthorized("Authentication is not available now") + } + val auth = this.headers[HttpHeaders.Authorization] ?: + throw unauthorized("Authorization header was not found in the request") + val tokenReq = extractToken(auth) + if (tokenEnv != tokenReq) throw unauthorized("Authentication failed, token did not match") +} fun getHTTPBasicAuthCredentials(request: ApplicationRequest): Pair<String, String> { val authHeader = getAuthorizationHeader(request) diff --git a/util/src/test/kotlin/AuthTokenTest.kt b/util/src/test/kotlin/AuthTokenTest.kt @@ -0,0 +1,34 @@ +import org.junit.Test +import tech.libeufin.util.extractToken +import java.lang.Exception + +class AuthTokenTest { + @Test + fun test() { + val tok = extractToken("Bearer secret-token:XXX") + assert(tok == "secret-token:XXX") + val tok_0 = extractToken("Bearer secret-token:XXX%20YYY") + assert(tok_0 == "secret-token:XXX YYY") + val tok_1 = extractToken("Bearer secret-token:XXX YYY") + assert(tok_1 == "secret-token:XXX YYY") + val tok_2 = extractToken("Bearer secret-token:XXX ") + assert(tok_2 == "secret-token:XXX ") + + val malformedAuths = listOf( + "", "XXX", "Bearer", "Bearer ", "Bearer XXX", + "BearerXXX", "XXXBearer", "Bearer secret-token", + "Bearer secret-token:", " Bearer", " Bearer secret-token:XXX", + ":: ::" + ) + for (token in malformedAuths) { + try { + extractToken(token) + } catch (e: Exception) { + assert(e is UtilError) + continue + } + println("Error: '$token' made it through") + assert(false) // should never arrive here. + } + } +} +\ No newline at end of file