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:
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;