libeufin

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

commit ec3273434e99ef6877388e86ed6671d28b099991
parent cf1456b82766a7c13338a59c84f6d8b1f38e9ad9
Author: Antoine A <>
Date:   Tue, 19 Dec 2023 17:21:39 +0000

Create challenge when 2FA is enabled

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 3+--
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 5+++++
Mbank/src/main/kotlin/tech/libeufin/bank/Tan.kt | 25++++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 3+++
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 1+
Abank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/CoreBankApiTest.kt | 12++++++++++++
Mbank/src/test/kotlin/helpers.kt | 2++
Mdatabase-versioning/libeufin-bank-0002.sql | 2+-
10 files changed, 113 insertions(+), 5 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -269,10 +269,9 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { patch("/accounts/{USERNAME}") { val req = call.receive<AccountReconfiguration>() val res = patchAccount(db, ctx, req, username, isAdmin) - println(req) - println(res) when (res) { AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent) + AccountPatchResult.TanRequired -> call.respondChallenge(db, req) AccountPatchResult.UnknownAccount -> throw unknownAccount(username) AccountPatchResult.NonAdminName -> throw conflict( "non-admin user cannot change their legal name", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -396,7 +396,8 @@ class EditAccount : CliktCommand( AccountPatchResult.NonAdminName, AccountPatchResult.NonAdminCashout, AccountPatchResult.NonAdminDebtLimit, - AccountPatchResult.NonAdminContact -> { + AccountPatchResult.NonAdminContact, + AccountPatchResult.TanRequired -> { // Unreachable as we edit account as admin } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -111,6 +111,11 @@ sealed class Option<out T> { } } +@Serializable +data class TanChallenge( + val challenge_id: Long +) + /** * HTTP response type of successful token refresh. * access_token is the Crockford encoding of the 32 byte diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt @@ -19,8 +19,31 @@ package tech.libeufin.bank import java.security.SecureRandom -import java.util.UUID +import java.time.Instant +import java.time.Duration import java.text.DecimalFormat +import kotlinx.serialization.json.Json +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.application.* + + +inline suspend fun <reified B> ApplicationCall.respondChallenge(db: Database, body: B) { + val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), body); + val code = Tan.genCode() + val id = db.tan.new( + login = username, + body = json, + code = code, + now = Instant.now(), + retryCounter = TAN_RETRY_COUNTER, + validityPeriod = TAN_VALIDITY_PERIOD + ) + respond( + status = HttpStatusCode.Accepted, + message = TanChallenge(id) + ) +} object Tan { private val CODE_FORMAT = DecimalFormat("00000000"); diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -202,6 +202,7 @@ class AccountDAO(private val db: Database) { NonAdminDebtLimit, NonAdminContact, MissingTanInfo, + TanRequired, Success } @@ -239,6 +240,7 @@ class AccountDAO(private val db: Database) { TanChannel.sms -> if (phone.get() != null) "false" else "phone IS NULL" TanChannel.email -> if (email.get() != null) "false" else "email IS NULL" }}) as missing_tan_info + ,(tan_channel IS NOT NULL) as tan_required FROM customers JOIN bank_accounts ON customer_id=owning_customer_id @@ -271,6 +273,7 @@ class AccountDAO(private val db: Database) { it.getBoolean("phone_change") -> return@transaction AccountPatchResult.NonAdminContact it.getBoolean("email_change") -> return@transaction AccountPatchResult.NonAdminContact it.getBoolean("missing_tan_info") -> return@transaction AccountPatchResult.MissingTanInfo + it.getBoolean("tan_required") -> return@transaction AccountPatchResult.TanRequired else -> it.getLong("customer_id") } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -87,6 +87,7 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val val account = AccountDAO(this) val transaction = TransactionDAO(this) val token = TokenDAO(this) + val tan = TanDAO(this) suspend fun monitor( params: MonitorParams diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -0,0 +1,61 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank + +import tech.libeufin.util.* +import tech.libeufin.bank.* +import java.util.concurrent.TimeUnit +import java.time.Duration +import java.time.Instant + +/** Data access logic for tan challenged */ +class TanDAO(private val db: Database) { + /** Update in-db conversion config */ + suspend fun new( + login: String, + body: String, + code: String, + now: Instant, + retryCounter: Int, + validityPeriod: Duration + ): Long = db.serializable { + it.transaction { conn -> + // Get user ID + val customer_id = conn.prepareStatement(""" + SELECT customer_id FROM customers WHERE login = ? + """).run { + setString(1, login); + oneOrNull { + it.getLong(1) + }!! // TODO handle case where account is deleted ? - HTTP status asking to retry + } + var stmt = conn.prepareStatement("SELECT tan_challenge_create(?, ?, ?, ?, ?, ?, NULL, NULL)") + stmt.setString(1, body) + stmt.setString(2, code) + stmt.setLong(3, now.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod)) + stmt.setInt(5, retryCounter) + stmt.setLong(6, customer_id) + stmt.oneOrNull { + it.getLong(1) + }!! // TODO handle database weirdness + } + } +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -479,6 +479,18 @@ class CoreBankAccountsApiTest { "is_public" to true } }.assertConflict(TalerErrorCode.END) + + // Check 2FA + client.patchA("/accounts/merchant") { + json { "tan_channel" to "sms" } + }.assertNoContent() + client.patchA("/accounts/merchant") { + json { "is_public" to false } + }.assertAccepted() + client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> + // Check request patch did not happen + assert(obj.is_public) + } } // Test admin-only account patch diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -287,6 +287,8 @@ suspend fun HttpResponse.assertOk(): HttpResponse = assertStatus(HttpStatusCode.OK, null) suspend fun HttpResponse.assertNoContent(): HttpResponse = assertStatus(HttpStatusCode.NoContent, null) +suspend fun HttpResponse.assertAccepted(): HttpResponse + = assertStatus(HttpStatusCode.Accepted, null) suspend fun HttpResponse.assertNotFound(err: TalerErrorCode?): HttpResponse = assertStatus(HttpStatusCode.NotFound, err) suspend fun HttpResponse.assertUnauthorized(): HttpResponse diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql @@ -24,7 +24,7 @@ SET search_path TO libeufin_bank; ALTER TABLE customers ADD tan_channel tan_enum NULL; -CREATE TABLE tan_challenges +CREATE TABLE tan_challenges -- TODO add op_enum as body is not enough to differenciate operation kind (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE ,body TEXT NOT NULL ,code TEXT NOT NULL