libeufin

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

commit f7f37e16b2ca0bbedda3aa9f6cc84975e1ddb2ff
parent 3cf09bce45f834c53ff1aa0222e0a9eefa45b23b
Author: Antoine A <>
Date:   Fri, 15 Nov 2024 18:15:20 +0100

bank: require 2FA for token creation

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 41++++++++++++++++++++++++++---------------
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 50+++++++++++++++++++++++++++++++++++++++++---------
Mbank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt | 9++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 19++++++++++++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 45+++++++++++++++++++++++----------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 2+-
Mbank/src/test/kotlin/CoreBankApiTest.kt | 37++++++++++++++++++++++++++++++-------
Mbank/src/test/kotlin/DatabaseTest.kt | 4++--
Mbank/src/test/kotlin/GcTest.kt | 2+-
Mbank/src/test/kotlin/helpers.kt | 6++++--
Mbank/src/test/kotlin/routines.kt | 4++++
Mcommon/src/main/kotlin/test/helpers.kt | 2+-
Adatabase-versioning/libeufin-bank-0011.sql | 24++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtestbench/src/test/kotlin/MigrationTest.kt | 3+++
17 files changed, 256 insertions(+), 80 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -37,6 +37,6 @@ val RESERVED_ACCOUNTS = setOf("admin", "bank") const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5 // API version -const val COREBANK_API_VERSION: String = "5:3:2" +const val COREBANK_API_VERSION: String = "6:0:3" const val CONVERSION_API_VERSION: String = "0:1:0" const val INTEGRATION_API_VERSION: String = "4:0:4" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -79,7 +79,8 @@ enum class Operation { account_auth_reconfig, bank_transaction, cashout, - withdrawal + withdrawal, + create_token } enum class WireMethod { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -36,6 +36,7 @@ import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult import tech.libeufin.bank.db.Database import tech.libeufin.bank.db.TanDAO.TanSendResult import tech.libeufin.bank.db.TanDAO.TanSolveResult +import tech.libeufin.bank.db.TokenDAO.TokenCreationResult import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult @@ -88,9 +89,9 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) auth(db, cfg.pwCrypto, TokenLogicalScope.refreshable, cfg.basicAuthCompat, allowPw = true) { post("/accounts/{USERNAME}/token") { + val (req, challenge) = call.receiveChallenge<TokenRequest>(db, Operation.create_token) + val existingToken = call.authToken - val req = call.receive<TokenRequest>() - if (existingToken != null) { // This block checks permissions ONLY IF the call was authenticated with a token val refreshingToken = db.token.access(existingToken, Instant.now()) ?: throw internalServerError( @@ -118,23 +119,24 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { throw badRequest("Bad token duration: ${e.message}") } } - if (!db.token.create( + when (db.token.create( username = call.username, content = token.raw, creationTime = creationTime, expirationTime = expirationTimestamp, scope = req.scope, isRefreshable = req.refreshable, - description = req.description + description = req.description, + is2fa = existingToken != null || challenge != null )) { - throw internalServerError("Failed at inserting new token in the database") - } - call.respond( - TokenSuccessResponse( - access_token = "$TOKEN_PREFIX$token", - expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) + TokenCreationResult.TanRequired -> call.respondChallenge(db, Operation.create_token, req) + TokenCreationResult.Success -> call.respond( + TokenSuccessResponse( + access_token = "$TOKEN_PREFIX$token", + expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) + ) ) - ) + } } } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { @@ -720,7 +722,7 @@ private fun Routing.coreBankCashoutApi(db: Database, cfg: BankConfig) = conditio } private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { + optAuth(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { val id = call.longPath("CHALLENGE_ID") val res = db.tan.send( @@ -729,13 +731,15 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { code = Tan.genCode(), timestamp = Instant.now(), retryCounter = TAN_RETRY_COUNTER, - validityPeriod = TAN_VALIDITY_PERIOD + validityPeriod = TAN_VALIDITY_PERIOD, + isAuth = call.isAuthenticated ) when (res) { TanSendResult.NotFound -> throw notFound( "Challenge $id not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) + TanSendResult.AuthRequired -> throw missingAuth() is TanSendResult.Success -> { res.tanCode?.run { val (tanScript, tanEnv) = cfg.tanChannels[res.tanChannel] @@ -776,8 +780,13 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { } db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD) } + val tan_info = if (call.isAuthenticated) { + res.tanInfo + } else { + "REDACTED" + } call.respond(TanTransmission( - tan_info = res.tanInfo, + tan_info = tan_info, tan_channel = res.tanChannel )) } @@ -791,7 +800,8 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { id = id, username = call.username, code = code, - timestamp = Instant.now() + timestamp = Instant.now(), + isAuth = call.isAuthenticated ) when (res) { TanSolveResult.NotFound -> throw notFound( @@ -811,6 +821,7 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { "Challenge expired", TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED ) + TanSolveResult.AuthRequired -> throw missingAuth() is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -37,6 +37,9 @@ import java.time.Instant private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-auth") +/** Used to store if the current request is authenticated */ +private val AUTH = AttributeKey<Boolean>("auth") + /** Used to store if the currently authenticated user is admin */ private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin") @@ -48,6 +51,9 @@ const val TOKEN_PREFIX = "secret-token:" /** Get username of the request account */ val ApplicationCall.username: String get() = parameters.expect("USERNAME") +/** Check if current request is authenticated */ +val ApplicationCall.isAuthenticated: Boolean get() = attributes.getOrNull(AUTH) == true + /** Check if current auth account is admin */ val ApplicationCall.isAdmin: Boolean get() = attributes.getOrNull(AUTH_IS_ADMIN) == true @@ -74,14 +80,12 @@ fun Route.authAdmin( if (username != "admin") { throw unauthorized("Only administrator allowed") } - this.attributes.put(AUTH_IS_ADMIN, true) } else { val username = try { this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw) } catch (e: Exception) { null } - this.attributes.put(AUTH_IS_ADMIN, username == "admin") } } @@ -113,9 +117,34 @@ fun Route.auth( throw unauthorized("Customer $authUsername have no right on $username account") } } - this.attributes.put(AUTH_IS_ADMIN, authUsername == "admin") } +/** + * Create an optionally authenticated route for [scope]. + * + * You can check is the currently authenticated user is admin using [isAdmin]. + **/ +fun Route.optAuth( + db: Database, + pwCrypto: PwCrypto, + scope: TokenLogicalScope, + compatPw: Boolean, + callback: Route.() -> Unit +): Route = intercept("Auth", callback) { + val header = request.headers[HttpHeaders.Authorization] + if (header != null) { + val authUsername = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw) + if (authUsername != username) { + throw unauthorized("Customer $authUsername have no right on $username account") + } + } +} + +fun missingAuth(): ApiException = unauthorized( + "Authorization header not found", + TalerErrorCode.GENERIC_PARAMETER_MISSING +) + /** * Authenticate an HTTP request for [requiredScope] according to the scheme that is mentioned * in the Authorization header. @@ -134,11 +163,10 @@ private suspend fun ApplicationCall.authenticateBankRequest( // Basic auth challenge if (header == null) { - response.header(HttpHeaders.WWWAuthenticate, "Basic realm=\"LibEuFin Bank\", charset=\"UTF-8\"") - throw unauthorized( - "Authorization header not found", - TalerErrorCode.GENERIC_PARAMETER_MISSING - ) + if (allowPw) { + response.header(HttpHeaders.WWWAuthenticate, "Basic realm=\"LibEuFin Bank\", charset=\"UTF-8\"") + } + throw missingAuth() } // Parse header @@ -146,11 +174,15 @@ private suspend fun ApplicationCall.authenticateBankRequest( "Authorization is invalid", TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) - return when (scheme) { + val username = when (scheme) { "Basic" -> doBasicAuth(db, content, pwCrypto, allowPw, compatPw) "Bearer" -> doTokenAuth(db, content, requiredScope) else -> throw unauthorized("Authorization method '$scheme' wrong or not supported") } + + this.attributes.put(AUTH, true) + this.attributes.put(AUTH_IS_ADMIN, username == "admin") + return username } /** diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt @@ -79,17 +79,16 @@ class CreateToken : CliktCommand("create-token") { } } val token = Base32Crockford32B.secureRand() - if (!db.token.create( + db.token.create( username = username, content = token.raw, creationTime = now, expirationTime = expirationTimestamp, scope = scope, isRefreshable = refreshable, - description = description - )) { - throw internalServerError("Unknown account $username") - } + description = description, + is2fa = true + ) println("$TOKEN_PREFIX$token") } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -62,6 +62,7 @@ class TanDAO(private val db: Database) { sealed interface TanSendResult { data class Success(val tanInfo: String, val tanChannel: TanChannel, val tanCode: String?): TanSendResult data object NotFound: TanSendResult + data object AuthRequired: TanSendResult } /** Request TAN challenge transmission */ @@ -71,9 +72,10 @@ class TanDAO(private val db: Database) { code: String, timestamp: Instant, retryCounter: Int, - validityPeriod: Duration + validityPeriod: Duration, + isAuth: Boolean, ) = db.serializable( - "SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info FROM tan_challenge_send(?,?,?,?,?,?)" + "SELECT out_no_op, out_tan_code, out_tan_channel, out_tan_info, out_auth_required FROM tan_challenge_send(?,?,?,?,?,?,?)" ) { setLong(1, id) setString(2, username) @@ -81,9 +83,11 @@ class TanDAO(private val db: Database) { setLong(4, timestamp.micros()) setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod)) setInt(6, retryCounter) + setBoolean(7, isAuth) one { when { it.getBoolean("out_no_op") -> TanSendResult.NotFound + it.getBoolean("out_auth_required") -> TanSendResult.AuthRequired else -> TanSendResult.Success( tanInfo = it.getString("out_tan_info"), tanChannel = it.getEnum("out_tan_channel"), @@ -114,6 +118,7 @@ class TanDAO(private val db: Database) { data object NoRetry: TanSolveResult data object Expired: TanSolveResult data object BadCode: TanSolveResult + data object AuthRequired: TanSolveResult } /** Solve TAN challenge */ @@ -121,19 +126,22 @@ class TanDAO(private val db: Database) { id: Long, username: String, code: String, - timestamp: Instant + timestamp: Instant, + isAuth: Boolean, ) = db.serializable( """ SELECT out_ok, out_no_op, out_no_retry, out_expired, - out_body, out_op, out_channel, out_info - FROM tan_challenge_try(?,?,?,?) + out_body, out_op, out_channel, out_info, + out_auth_required + FROM tan_challenge_try(?,?,?,?,?) """ ) { setLong(1, id) setString(2, username) setString(3, code) setLong(4, timestamp.micros()) + setBoolean(5, isAuth) one { when { it.getBoolean("out_ok") -> TanSolveResult.Success( @@ -142,6 +150,7 @@ class TanDAO(private val db: Database) { channel = it.getOptEnum<TanChannel>("out_channel"), info = it.getString("out_info") ) + it.getBoolean("out_auth_required") -> TanSolveResult.AuthRequired it.getBoolean("out_no_op") -> TanSolveResult.NotFound it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry it.getBoolean("out_expired") -> TanSolveResult.Expired diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt @@ -30,6 +30,12 @@ import java.time.Instant /** Data access logic for auth tokens */ class TokenDAO(private val db: Database) { + /** Result status of token creation */ + sealed interface TokenCreationResult { + data object Success: TokenCreationResult + data object TanRequired: TokenCreationResult + } + /** Create new token for [username] */ suspend fun create( username: String, @@ -38,34 +44,29 @@ class TokenDAO(private val db: Database) { expirationTime: Instant, scope: TokenScope, isRefreshable: Boolean, - description: String? - ): Boolean = db.serializable( + description: String?, + is2fa: Boolean + ): TokenCreationResult = db.serializable( """ - INSERT INTO bearer_tokens ( - content, - creation_time, - expiration_time, - scope, - bank_customer, - is_refreshable, - description, - last_access - ) VALUES ( - ?,?,?,?::token_scope_enum, - (SELECT customer_id FROM customers WHERE username=? AND deleted_at IS NULL), - ?,?,? + SELECT out_tan_required FROM create_token( + ?,?,?,?,?::token_scope_enum,?,?,? ) """ ) { - setBytes(1, content) - setLong(2, creationTime.micros()) - setLong(3, expirationTime.micros()) - setString(4, scope.name) - setString(5, username) + setString(1, username) + setBytes(2, content) + setLong(3, creationTime.micros()) + setLong(4, expirationTime.micros()) + setString(5, scope.name) setBoolean(6, isRefreshable) setString(7, description) - setLong(8, creationTime.micros()) - executeUpdateViolation() + setBoolean(8, is2fa) + one { + when { + it.getBoolean("out_tan_required") -> TokenCreationResult.TanRequired + else -> TokenCreationResult.Success + } + } } /** Get info for [token] */ diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -31,7 +31,7 @@ private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-tx-dao") /** Data access logic for transactions */ class TransactionDAO(private val db: Database) { - /** Result status of bank transaction creation .*/ + /** Result status of bank transaction creation */ sealed interface BankTransactionResult { data class Success(val id: Long): BankTransactionResult data object UnknownCreditor: BankTransactionResult diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -226,6 +226,34 @@ class CoreBankTokenApiTest { }.assertBadRequest() } + @Test + fun post2FA() = bankSetup { db -> + // Setup a known phone 2FA + client.patchA("/accounts/merchant") { + json { + "contact_data" to obj { + "phone" to "+12345" + } + "tan_channel" to "sms" + } + }.assertChallenge().assertNoContent() + + // Check creating a token requires to solve an unauthenticated challenge + val challenge = client.postPw("/accounts/merchant/token") { + json { "scope" to "readonly" } + }.assertAcceptedJson<TanChallenge>() + val transmission = client.post("/accounts/merchant/challenge/${challenge.challenge_id}") + .assertOkJson<TanTransmission>() + assertEquals("REDACTED", transmission.tan_info) // Check phone number is hidden + val code = tanCode("+12345") + client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") { + json { "tan" to code } + }.assertNoContent() + client.postPw("/accounts/merchant/token") { + headers[X_CHALLENGE_ID] = "${challenge.challenge_id}" + }.assertOkJson<TokenSuccessResponse>() + } + // DELETE /accounts/USERNAME/token @Test fun delete() = bankSetup { @@ -2000,12 +2028,7 @@ class CoreBankTanApiTest { @Test fun sendTanErr() = bankSetup("test_tan_err.conf") { // Check fail - client.patchAdmin("/accounts/merchant") { - json { - "contact_data" to obj { "phone" to "+1234" } - "tan_channel" to "sms" - } - }.assertNoContent() + fillTanInfo("merchant") client.patchA("/accounts/merchant") { json { "is_public" to false } }.assertAcceptedJson<TanChallenge> { @@ -2017,7 +2040,7 @@ class CoreBankTanApiTest { // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm @Test fun confirm() = bankSetup { - authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm") + authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm", obj { "tan" to "code" }) fillTanInfo("merchant") diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -139,8 +139,8 @@ class DatabaseTest { fun tanChallenge() = bankSetup { db -> db.conn { conn -> val createStmt = conn.prepareStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)") val markSentStmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)") - val tryStmt = conn.prepareStatement("SELECT out_ok, out_no_retry, out_expired FROM tan_challenge_try(?,'customer',?,?)") - val sendStmt = conn.prepareStatement("SELECT out_tan_code FROM tan_challenge_send(?,'customer',?,?,?,?)") + val tryStmt = conn.prepareStatement("SELECT out_ok, out_no_retry, out_expired FROM tan_challenge_try(?,'customer',?,?,true)") + val sendStmt = conn.prepareStatement("SELECT out_tan_code FROM tan_challenge_send(?,'customer',?,?,?,?,true)") val validityPeriod = Duration.ofHours(1) val retransmissionPeriod: Duration = Duration.ofMinutes(1) diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -87,7 +87,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, null)) + db.token.create(account, ByteArray(32).rand(), time, time, TokenScope.readonly, false, null, true) db.tan.new(account, Operation.cashout, "", "", time, 0, Duration.ZERO, null, null) } } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -286,8 +286,10 @@ suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) { }.assertNoContent() } -suspend fun ApplicationTestBuilder.fillTanInfo(account: String) { - client.patchAdmin("/accounts/$account") { +suspend fun ApplicationTestBuilder.fillTanInfo(username: String) { + // Create a token before we require 2fa for it + client.cachedToken(username) + client.patchAdmin("/accounts/$username") { json { "contact_data" to obj { "phone" to "+${Random.nextInt(0, 10000)}" diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt @@ -44,11 +44,13 @@ suspend fun ApplicationTestBuilder.authRoutine( // No header client.request(path) { this.method = method + if (body != null) json(body) }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING) // Bad header client.request(path) { this.method = method + if (body != null) json(body) headers[HttpHeaders.Authorization] = "WTF" }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) @@ -56,12 +58,14 @@ suspend fun ApplicationTestBuilder.authRoutine( // Not exchange account client.request(path) { this.method = method + if (body != null) json(body) tokenAuth(client, "merchant") }.assertUnauthorized() } else if (!allowAdmin) { // Check no admin client.request(path) { this.method = method + if (body != null) json(body) tokenAuth(client, "admin") }.assertUnauthorized() } diff --git a/common/src/main/kotlin/test/helpers.kt b/common/src/main/kotlin/test/helpers.kt @@ -66,7 +66,7 @@ suspend inline fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B) /* ----- Auth ----- */ -typealias RequestLambda = suspend HttpRequestBuilder.() -> Unit; +typealias RequestLambda = suspend HttpRequestBuilder.() -> Unit; /** Auto token auth GET request */ suspend fun HttpClient.getA(url: String, builder: RequestLambda = {}): HttpResponse diff --git a/database-versioning/libeufin-bank-0011.sql b/database-versioning/libeufin-bank-0011.sql @@ -0,0 +1,24 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2024 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-bank-0011', NULL, NULL); +SET search_path TO libeufin_bank; + +-- Add new token scope 'revenue' +ALTER TYPE op_enum ADD VALUE 'create_token'; + +COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -187,6 +187,50 @@ IF in_max_amount.val < out_max_amount.val END IF; END $$; +CREATE FUNCTION create_token( + IN in_username TEXT, + IN in_content BYTEA, + IN in_creation_time INT8, + IN in_expiration_time INT8, + IN in_scope token_scope_enum, + IN in_refreshable BOOLEAN, + IN in_description TEXT, + IN in_is_tan BOOLEAN, + OUT out_tan_required BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE +local_customer_id INT8; +BEGIN +-- Get account id and check if 2FA is required +SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL) +INTO local_customer_id, out_tan_required +FROM customers JOIN bank_accounts ON owning_customer_id = customer_id +WHERE username = in_username AND deleted_at IS NULL; +IF out_tan_required THEN + RETURN; +END IF; +INSERT INTO bearer_tokens ( + content, + creation_time, + expiration_time, + scope, + bank_customer, + is_refreshable, + description, + last_access +) VALUES ( + in_content, + in_creation_time, + in_expiration_time, + in_scope, + local_customer_id, + in_refreshable, + in_description, + in_creation_time +); +END $$; + CREATE FUNCTION bank_wire_transfer( IN in_creditor_account_id INT8, IN in_debtor_account_id INT8, @@ -1430,15 +1474,17 @@ INSERT INTO tan_challenges ( END $$; COMMENT ON FUNCTION tan_challenge_create IS 'Create a new challenge, return the generated id'; -CREATE FUNCTION tan_challenge_send ( +CREATE FUNCTION tan_challenge_send( IN in_challenge_id INT8, IN in_username TEXT, IN in_code TEXT, -- New code to use if the old code expired IN in_timestamp INT8, IN in_validity_period INT8, IN in_retry_counter INT4, + IN in_auth BOOLEAN, -- Error status OUT out_no_op BOOLEAN, + OUT out_auth_required BOOLEAN, -- Success return OUT out_tan_code TEXT, -- TAN code to send, NULL if nothing should be sent OUT out_tan_channel tan_enum, -- TAN channel to use, NULL if nothing should be sent @@ -1462,12 +1508,16 @@ FROM customers WHERE username = in_username AND deleted_at IS NULL; SELECT (in_timestamp >= expiration_date OR retry_counter <= 0) AND confirmation_date IS NULL ,in_timestamp >= retransmission_date AND confirmation_date IS NULL + ,NOT in_auth AND op != 'create_token' ,code, COALESCE(tan_channel, out_tan_channel), COALESCE(tan_info, out_tan_info) -INTO expired, retransmit, out_tan_code, out_tan_channel, out_tan_info -FROM tan_challenges WHERE challenge_id = in_challenge_id AND customer = account_id -FOR UPDATE; -IF NOT FOUND THEN - out_no_op = true; +INTO expired, retransmit, out_auth_required, out_tan_code, out_tan_channel, out_tan_info +FROM tan_challenges WHERE challenge_id = in_challenge_id AND customer = account_id; +out_no_op = NOT FOUND; +IF out_no_op OR out_auth_required THEN + IF out_no_op AND NOT in_auth THEN + out_no_op=false; + out_auth_required=true; + END IF; RETURN; END IF; @@ -1501,11 +1551,13 @@ CREATE FUNCTION tan_challenge_try ( IN in_username TEXT, IN in_code TEXT, IN in_timestamp INT8, + IN in_auth BOOLEAN, -- Error status OUT out_ok BOOLEAN, OUT out_no_op BOOLEAN, OUT out_no_retry BOOLEAN, OUT out_expired BOOLEAN, + OUT out_auth_required BOOLEAN, -- Success return OUT out_op op_enum, OUT out_body TEXT, @@ -1518,14 +1570,29 @@ account_id INT8; BEGIN -- Retrieve account id SELECT customer_id INTO account_id FROM customers WHERE username = in_username AND deleted_at IS NULL; --- Check challenge + +-- Check if challenge exist and if auth is required +SELECT NOT in_auth AND op != 'create_token' +INTO out_auth_required +FROM tan_challenges +WHERE challenge_id = in_challenge_id AND customer = account_id; +out_no_op = NOT FOUND; +IF out_no_op OR out_auth_required THEN + IF out_no_op AND NOT in_auth THEN + out_no_op=false; + out_auth_required=true; + END IF; + RETURN; +END IF; + +-- Try to solve challenge UPDATE tan_challenges SET confirmation_date = CASE WHEN (retry_counter > 0 AND in_timestamp < expiration_date AND code = in_code) THEN in_timestamp ELSE confirmation_date END, retry_counter = retry_counter - 1 -WHERE challenge_id = in_challenge_id AND customer = account_id +WHERE challenge_id = in_challenge_id RETURNING confirmation_date IS NOT NULL, retry_counter <= 0 AND confirmation_date IS NULL, diff --git a/testbench/src/test/kotlin/MigrationTest.kt b/testbench/src/test/kotlin/MigrationTest.kt @@ -80,6 +80,9 @@ class MigrationTest { // libeufin-bank-0010 conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0010.sql").readText()) + // libeufin-bank-0011 + conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0011.sql").readText()) + // libeufin-nexus-0001 conn.execSQLUpdate(Path("../database-versioning/libeufin-nexus-0001.sql").readText()) conn.execSQLUpdate("""