libeufin

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

commit c9516d41a4dfcb909c779849935976530e22e023
parent a3e1b26adbb12f4a88b2aa40d9b6fc9d57ad66ac
Author: Antoine A <>
Date:   Wed, 12 Jun 2024 10:14:34 +0200

bank: new token api and revenue token scope

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 18++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 18++++++++++++++----
Mbank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 13+++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 67++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mbank/src/test/kotlin/GcTest.kt | 2+-
Mdatabase-versioning/libeufin-bank-0005.sql | 10++++++++++
8 files changed, 186 insertions(+), 29 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -223,6 +223,7 @@ data class AccountReconfiguration( data class TokenRequest( val scope: TokenScope, val duration: RelativeTime? = null, + val description: String? = null, val refreshable: Boolean = false ) @@ -278,6 +279,7 @@ enum class TanChannel { enum class TokenScope { readonly, readwrite, + revenue, refreshable // Not spec'd as a scope! } @@ -290,6 +292,22 @@ data class BearerToken( ) @Serializable +data class TokenInfo( + val creationTime: TalerProtocolTimestamp, + val expirationTime: TalerProtocolTimestamp, + val scope: TokenScope, + val isRefreshable: Boolean, + val description: String? = null, + val last_access: TalerProtocolTimestamp +) + + +@Serializable +data class TokenInfos ( + val tokens: List<TokenInfo> +) + +@Serializable data class Config( val currency: String, val currency_specification: CurrencySpecification, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -90,12 +90,12 @@ private fun Routing.coreBankTokenApi(db: Database) { if (existingToken != null) { // This block checks permissions ONLY IF the call was authenticated with a token - val refreshingToken = db.token.get(existingToken) ?: throw internalServerError( + val refreshingToken = db.token.access(existingToken, Instant.now()) ?: throw internalServerError( "Token used to auth not found in the database!" ) - if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) + if (!validScope(req.scope, refreshingToken.scope)) throw forbidden( - "Cannot generate RW token from RO", + "Impossible to refresh a token with a larger scope", TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT ) } @@ -121,7 +121,8 @@ private fun Routing.coreBankTokenApi(db: Database) { creationTime = creationTime, expirationTime = expirationTimestamp, scope = req.scope, - isRefreshable = req.refreshable + isRefreshable = req.refreshable, + description = req.description )) { throw internalServerError("Failed at inserting new token in the database") } @@ -139,6 +140,15 @@ private fun Routing.coreBankTokenApi(db: Database) { db.token.delete(token) call.respond(HttpStatusCode.NoContent) } + get("/accounts/{USERNAME}/token") { + val params = PageParams.extract(call.request.queryParameters) + val tokens = db.token.page(params, username) + if (tokens.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(TokenInfos(tokens)) + } + } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt @@ -28,7 +28,7 @@ import tech.libeufin.bank.db.Database import tech.libeufin.common.* fun Routing.revenueApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readonly) { + auth(db, TokenScope.revenue) { get("/accounts/{USERNAME}/taler-revenue/config") { call.respond(RevenueConfig( currency = ctx.regionalCurrency diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -148,6 +148,15 @@ private suspend fun doBasicAuth(db: Database, encoded: String): String { return login } +fun validScope(required: TokenScope, scope: TokenScope): Boolean { + val validScopes = when (required) { + TokenScope.readonly -> setOf(TokenScope.readonly, TokenScope.readwrite) + TokenScope.readwrite -> setOf(TokenScope.readwrite) + TokenScope.revenue -> setOf(TokenScope.readonly, TokenScope.readwrite, TokenScope.revenue) + TokenScope.refreshable -> return true + } + return validScopes.contains(scope) +} /** * Performs the secret-token HTTP Bearer Authentication. * @@ -169,12 +178,12 @@ private suspend fun ApplicationCall.doTokenAuth( e.message, TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) } - val token: BearerToken = db.token.get(decoded) ?: throw unauthorized("Unknown token") + val token: BearerToken = db.token.access(decoded, Instant.now()) ?: throw unauthorized("Unknown token") when { token.expirationTime.isBefore(Instant.now()) -> throw unauthorized("Expired auth token") - token.scope == TokenScope.readonly && requiredScope == TokenScope.readwrite + !validScope(requiredScope, token.scope) -> throw unauthorized("Auth token has insufficient scope") !token.isRefreshable && requiredScope == TokenScope.refreshable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt @@ -19,12 +19,9 @@ package tech.libeufin.bank.db -import tech.libeufin.bank.BearerToken -import tech.libeufin.bank.TokenScope -import tech.libeufin.common.asInstant -import tech.libeufin.common.db.executeUpdateViolation -import tech.libeufin.common.db.oneOrNull -import tech.libeufin.common.micros +import tech.libeufin.bank.* +import tech.libeufin.common.db.* +import tech.libeufin.common.* import java.time.Instant /** Data access logic for auth tokens */ @@ -36,7 +33,8 @@ class TokenDAO(private val db: Database) { creationTime: Instant, expirationTime: Instant, scope: TokenScope, - isRefreshable: Boolean + isRefreshable: Boolean, + description: String? ): Boolean = db.serializable { conn -> // TODO single query val bankCustomer = conn.prepareStatement(""" @@ -52,8 +50,10 @@ class TokenDAO(private val db: Database) { expiration_time, scope, bank_customer, - is_refreshable - ) VALUES (?, ?, ?, ?::token_scope_enum, ?, ?) + is_refreshable, + description, + last_access + ) VALUES (?,?,?,?::token_scope_enum,?,?,?,?) """) stmt.setBytes(1, content) stmt.setLong(2, creationTime.micros()) @@ -61,23 +61,27 @@ class TokenDAO(private val db: Database) { stmt.setString(4, scope.name) stmt.setLong(5, bankCustomer) stmt.setBoolean(6, isRefreshable) + stmt.setString(7, description) + stmt.setLong(8, creationTime.micros()) stmt.executeUpdateViolation() } /** Get info for [token] */ - suspend fun get(token: ByteArray): BearerToken? = db.conn { conn -> + suspend fun access(token: ByteArray, accessTime: Instant): BearerToken? = db.conn { conn -> val stmt = conn.prepareStatement(""" - SELECT + UPDATE bearer_tokens + SET last_access=? + FROM customers + WHERE bank_customer=customer_id AND content=? AND deleted_at IS NULL + RETURNING creation_time, expiration_time, login, scope, is_refreshable - FROM bearer_tokens - JOIN customers ON bank_customer=customer_id - WHERE content=? AND deleted_at IS NULL """) - stmt.setBytes(1, token) + stmt.setLong(1, accessTime.micros()) + stmt.setBytes(2, token) stmt.oneOrNull { BearerToken( creationTime = it.getLong("creation_time").asInstant(), @@ -97,4 +101,37 @@ class TokenDAO(private val db: Database) { stmt.setBytes(1, token) stmt.execute() } + + /** Get a page of all public accounts */ + suspend fun page(params: PageParams, login: String): List<TokenInfo> + = db.page( + params, + "bearer_token_id", + """ + SELECT + creation_time, + expiration_time, + scope, + is_refreshable, + description, + last_access, + bearer_token_id + FROM bearer_tokens JOIN customers + ON bank_customer=customer_id + WHERE deleted_at IS NULL AND login = ? AND + """, + { + setString(1, login) + 1 + } + ) { + TokenInfo( + creationTime = it.getTalerTimestamp("creation_time"), + expirationTime = it.getTalerTimestamp("expiration_time"), + scope = TokenScope.valueOf(it.getString("scope")), + isRefreshable = it.getBoolean("is_refreshable"), + description = it.getString("description"), + last_access = it.getTalerTimestamp("last_access"), + ) + } } \ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -65,7 +65,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertOkJson<TokenSuccessResponse> { // Checking that the token lifetime defaulted to 24 hours. - val token = db.token.get(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX))) + val token = db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)), Instant.now()) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } @@ -75,26 +75,68 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertOkJson<TokenSuccessResponse> { // Checking that the token lifetime defaulted to 24 hours. - val token = db.token.get(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX))) + val token = db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)), Instant.now()) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } - // Check refresh + // Check valid refresh scope + for ((fromScope, toScope) in listOf( + "readwrite" to "readwrite", + "readonly" to "readonly", + "revenue" to "revenue", + "readwrite" to "readonly", + "readwrite" to "revenue", + "readonly" to "revenue", + )) { + client.postA("/accounts/merchant/token") { + json { + "scope" to fromScope + "refreshable" to true + } + }.assertOkJson<TokenSuccessResponse> { + val token = it.access_token + client.post("/accounts/merchant/token") { + headers["Authorization"] = "Bearer $token" + json { "scope" to toScope } + }.assertOk() + } + } + + // Check invalid refresh scope + for ((fromScope, toScope) in listOf( + "readonly" to "readwrite", + "revenue" to "readonly", + "revenue" to "readwrite" + )) { + client.postA("/accounts/merchant/token") { + json { + "scope" to fromScope + "refreshable" to true + } + }.assertOkJson<TokenSuccessResponse> { + val token = it.access_token + client.post("/accounts/merchant/token") { + headers["Authorization"] = "Bearer $token" + json { "scope" to toScope } + }.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT) + } + } + + // Check no refreshable client.postA("/accounts/merchant/token") { json { "scope" to "readonly" - "refreshable" to true } }.assertOkJson<TokenSuccessResponse> { val token = it.access_token client.post("/accounts/merchant/token") { headers["Authorization"] = "Bearer $token" json { "scope" to "readonly" } - }.assertOk() + }.assertUnauthorized() } - // Check'forever' case. + // Check 'forever' case. client.postA("/accounts/merchant/token") { json { "scope" to "readonly" @@ -153,6 +195,37 @@ class CoreBankTokenApiTest { // Checking merchant can still be served by basic auth, after token deletion. client.getA("/accounts/merchant").assertOk() } + + // GET /accounts/USERNAME/token + @Test + fun get() = bankSetup { + // Check OK + for (account in listOf("merchant", "customer")) { + client.getA("/accounts/$account/token").assertNoContent() + } + client.postA("/accounts/merchant/token") { + json { "scope" to "readonly" } + }.assertOk() + client.postA("/accounts/merchant/token") { + json { "scope" to "readwrite" } + }.assertOk() + client.postA("/accounts/customer/token") { + json { + "scope" to "revenue" + "description" to "description" + } + }.assertOk() + client.getA("/accounts/merchant/token").assertOkJson<TokenInfos> { + assertEquals(2, it.tokens.size) + for (token in it.tokens) { + assertNull(token.description) + } + } + client.getA("/accounts/customer/token").assertOkJson<TokenInfos> { + assertEquals(1, it.tokens.size) + assertEquals("description", it.tokens[0].description) + } + } } class CoreBankAccountsApiTest { diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -81,7 +81,7 @@ class GcTest { // Create test tokens for (time in listOf(now, clean)) { for (account in listOf("old_account", "recent_account")) { - assert(db.token.create(account, ByteArray(32).rand(), time, time, TokenScope.readonly, false)) + assert(db.token.create(account, ByteArray(32).rand(), time, time, TokenScope.readonly, false, null)) db.tan.new(account, Operation.cashout, "", "", time, 0, Duration.ZERO, null, null) } } diff --git a/database-versioning/libeufin-bank-0005.sql b/database-versioning/libeufin-bank-0005.sql @@ -18,7 +18,17 @@ BEGIN; SELECT _v.register_patch('libeufin-bank-0005', NULL, NULL); SET search_path TO libeufin_bank; +-- Make withdrawal amount optional and add optional suggested_amount ALTER TABLE taler_withdrawal_operations ADD suggested_amount taler_amount; ALTER TABLE taler_withdrawal_operations ALTER COLUMN amount DROP NOT NULL; +-- Add description and last_access to bearer_tokens +ALTER TABLE bearer_tokens ADD description TEXT; +ALTER TABLE bearer_tokens ADD last_access INT8; +UPDATE bearer_tokens SET last_access=creation_time; +ALTER TABLE bearer_tokens ALTER COLUMN last_access SET NOT NULL; + +-- Add new token scope 'revenue' +ALTER TYPE token_scope_enum ADD VALUE 'revenue'; + COMMIT;