libeufin

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

commit 211de5e71511e0b3f4fbcadd7aa1c4ebf4403ae2
parent 4b54eb04a938fa6662ce8b61be740878ee5f3281
Author: MS <ms@taler.net>
Date:   Thu, 14 Sep 2023 16:08:24 +0200

RelativeTime parser.

Diffstat:
M.idea/workspace.xml | 40+++++++++++++++++++---------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Helpers.kt | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 108++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mbank/src/test/kotlin/JsonTest.kt | 25++++++++++++++++++++++++-
4 files changed, 181 insertions(+), 74 deletions(-)

diff --git a/.idea/workspace.xml b/.idea/workspace.xml @@ -6,11 +6,9 @@ <component name="ChangeListManager"> <list default="true" id="9436eb1e-de48-4f11-8ff7-f359340cb458" name="Changes" comment=""> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> - <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Database.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Database.kt" afterDir="false" /> <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt" afterDir="false" /> <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Main.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Main.kt" afterDir="false" /> - <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/AmountTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/AmountTest.kt" afterDir="false" /> - <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/LibeuFinApiTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/LibeuFinApiTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/JsonTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/JsonTest.kt" afterDir="false" /> </list> <option name="SHOW_DIALOG" value="false" /> <option name="HIGHLIGHT_CONFLICTS" value="true" /> @@ -72,8 +70,8 @@ &quot;settings.editor.splitter.proportion&quot;: &quot;0.31419808&quot; } }</component> - <component name="RunManager" selected="Gradle.LibeuFinApiTest.createAccountTest"> - <configuration name="AmountTest.parseAmountTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <component name="RunManager" selected="Gradle.JsonTest.unionTypeTest"> + <configuration name="AmountTest.parseTalerAmountTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> <ExternalSystemSettings> <option name="executionName" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> @@ -86,7 +84,7 @@ <list> <option value=":bank:test" /> <option value="--tests" /> - <option value="&quot;AmountTest.parseAmountTest&quot;" /> + <option value="&quot;AmountTest.parseTalerAmountTest&quot;" /> </list> </option> <option name="vmOptions" /> @@ -96,20 +94,20 @@ <DebugAllEnabled>false</DebugAllEnabled> <method v="2" /> </configuration> - <configuration name="AmountTest.parseTalerAmountTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <configuration name="CryptoUtilTest.passwordHashing" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> <ExternalSystemSettings> <option name="executionName" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value="--quiet" /> + <option name="scriptParameters" value="" /> <option name="taskDescriptions"> <list /> </option> <option name="taskNames"> <list> - <option value=":bank:test" /> + <option value=":util:test" /> <option value="--tests" /> - <option value="&quot;AmountTest.parseTalerAmountTest&quot;" /> + <option value="&quot;CryptoUtilTest.passwordHashing&quot;" /> </list> </option> <option name="vmOptions" /> @@ -119,20 +117,20 @@ <DebugAllEnabled>false</DebugAllEnabled> <method v="2" /> </configuration> - <configuration name="CryptoUtilTest.passwordHashing" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <configuration name="JsonTest.deserializationTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> <ExternalSystemSettings> <option name="executionName" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value="" /> + <option name="scriptParameters" value="--quiet" /> <option name="taskDescriptions"> <list /> </option> <option name="taskNames"> <list> - <option value=":util:test" /> + <option value=":bank:test" /> <option value="--tests" /> - <option value="&quot;CryptoUtilTest.passwordHashing&quot;" /> + <option value="&quot;JsonTest.deserializationTest&quot;" /> </list> </option> <option name="vmOptions" /> @@ -142,12 +140,12 @@ <DebugAllEnabled>false</DebugAllEnabled> <method v="2" /> </configuration> - <configuration name="DatabaseTest.bankAccountTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <configuration name="JsonTest.unionTypeTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> <ExternalSystemSettings> <option name="executionName" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value="" /> + <option name="scriptParameters" value="--quiet" /> <option name="taskDescriptions"> <list /> </option> @@ -155,7 +153,7 @@ <list> <option value=":bank:test" /> <option value="--tests" /> - <option value="&quot;DatabaseTest.bankAccountTest&quot;" /> + <option value="&quot;JsonTest.unionTypeTest&quot;" /> </list> </option> <option name="vmOptions" /> @@ -189,19 +187,19 @@ <method v="2" /> </configuration> <list> + <item itemvalue="Gradle.JsonTest.unionTypeTest" /> <item itemvalue="Gradle.CryptoUtilTest.passwordHashing" /> - <item itemvalue="Gradle.DatabaseTest.bankAccountTest" /> <item itemvalue="Gradle.AmountTest.parseTalerAmountTest" /> - <item itemvalue="Gradle.AmountTest.parseAmountTest" /> + <item itemvalue="Gradle.JsonTest.deserializationTest" /> <item itemvalue="Gradle.LibeuFinApiTest.createAccountTest" /> </list> <recent_temporary> <list> + <item itemvalue="Gradle.JsonTest.unionTypeTest" /> + <item itemvalue="Gradle.JsonTest.deserializationTest" /> <item itemvalue="Gradle.LibeuFinApiTest.createAccountTest" /> <item itemvalue="Gradle.CryptoUtilTest.passwordHashing" /> <item itemvalue="Gradle.AmountTest.parseTalerAmountTest" /> - <item itemvalue="Gradle.DatabaseTest.bankAccountTest" /> - <item itemvalue="Gradle.AmountTest.parseAmountTest" /> </list> </recent_temporary> </component> diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt @@ -20,11 +20,70 @@ package tech.libeufin.bank import io.ktor.http.* -import tech.libeufin.util.getIban +import tech.libeufin.util.* import java.lang.NumberFormatException -// HELPERS. FIXME: make unit tests for them. +// 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 = GENERIC_HTTP_HEADERS_MALFORMED, // 23 + "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 = GENERIC_INTERNAL_INVARIANT_FAILURE, + hint = "Customer not found, despite token mentions it.", + )) +} + +fun unauthorized(hint: String? = null): LibeufinBankException = + LibeufinBankException( + httpStatus = HttpStatusCode.Unauthorized, + talerError = TalerError( + code = BANK_LOGIN_FAILED, + hint = hint + ) + ) fun internalServerError(hint: String): LibeufinBankException = LibeufinBankException( httpStatus = HttpStatusCode.InternalServerError, @@ -33,9 +92,28 @@ fun internalServerError(hint: String): LibeufinBankException = hint = hint ) ) +fun badRequest( + hint: String? = null, + talerErrorCode: Int = GENERIC_JSON_INVALID +): LibeufinBankException = + LibeufinBankException( + httpStatus = HttpStatusCode.InternalServerError, + talerError = TalerError( + code = talerErrorCode, + 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 diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -16,6 +16,7 @@ * License along with LibEuFin; see the file COPYING. If not, see * <http://www.gnu.org/licenses/> */ +// Main HTTP handlers and related data definitions. package tech.libeufin.bank @@ -32,7 +33,11 @@ import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import kotlinx.serialization.json.Json +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.json.* import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level @@ -83,60 +88,56 @@ data class RegisterAccountRequest( val internal_payto_uri: String? = null ) -class LibeufinBankException( - val httpStatus: HttpStatusCode, - val talerError: TalerError -) : Exception(talerError.hint) - +/** + * This is the _internal_ representation of a RelativeTime + * JSON type. + */ +data class RelativeTime( + val d_us: Long +) /** - * Performs the HTTP basic authentication. Returns the - * authenticated customer on success, or null otherwise. + * This custom (de)serializer interprets the RelativeTime JSON + * type. In particular, it is responsible for converting the + * "forever" string into Long.MAX_VALUE. Any other numeric value + * is passed as is. */ -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 = GENERIC_HTTP_HEADERS_MALFORMED, // 23 - "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 -} +object RelativeTimeSerializer : KSerializer<RelativeTime> { + override fun serialize(encoder: Encoder, value: RelativeTime) { + throw internalServerError("Encoding of RelativeTime not implemented.") // API doesn't require this. + } + override fun deserialize(decoder: Decoder): RelativeTime { + val jsonInput = decoder as? JsonDecoder ?: throw internalServerError("RelativeTime had no JsonDecoder") + val json = try { + jsonInput.decodeJsonElement().jsonObject + } catch (e: Exception) { + throw badRequest(e.message) // JSON was malformed. + } + val maybeDUs = json["d_us"]?.jsonPrimitive ?: throw badRequest("Relative time invalid: d_us field not found") + if (maybeDUs.isString) { + if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found") + return RelativeTime(d_us = Long.MAX_VALUE) + } + val dUs: Long = maybeDUs.longOrNull ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") + return RelativeTime(d_us = dUs) + } -/* 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 = GENERIC_INTERNAL_INVARIANT_FAILURE, - hint = "Customer not found, despite token mentions it.", - )) + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("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 @@ -242,6 +243,13 @@ val webApp: Application.() -> Unit = { } } routing { + post("/accounts/{USERNAME}/auth-token") { + val customer = call.myAuth(TokenScope.readwrite) + val endpointOwner = call.expectUriComponent("USERNAME") + if (customer == null || customer.login != endpointOwner) + throw unauthorized("Auth failed or client has no rights") + + } post("/accounts") { // check if only admin. val maybeOnlyAdmin = db.configGet("only_admin_registrations") diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -1,7 +1,12 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule import org.junit.Test +import tech.libeufin.bank.RelativeTime +import tech.libeufin.bank.RelativeTimeSerializer +import tech.libeufin.bank.TokenRequest +import tech.libeufin.bank.TokenScope @Serializable data class MyJsonType( @@ -22,5 +27,23 @@ class JsonTest { """.trimIndent() Json.decodeFromString<MyJsonType>(serialized) } - + @Test + fun unionTypeTest() { + val jsonCfg = Json { + serializersModule = SerializersModule { + contextual(RelativeTime::class) { + RelativeTimeSerializer + } + } + } + assert(jsonCfg.decodeFromString<RelativeTime>("{\"d_us\": 3}").d_us == 3L) + assert(jsonCfg.decodeFromString<RelativeTime>("{\"d_us\": \"forever\"}").d_us == Long.MAX_VALUE) + val tokenReq = jsonCfg.decodeFromString<TokenRequest>(""" + { + "scope": "readonly", + "duration": {"d_us": 30} + } + """.trimIndent()) + assert(tokenReq.scope == TokenScope.readonly && tokenReq.duration.d_us == 30L) + } } \ No newline at end of file