commit ec3273434e99ef6877388e86ed6671d28b099991
parent cf1456b82766a7c13338a59c84f6d8b1f38e9ad9
Author: Antoine A <>
Date: Tue, 19 Dec 2023 17:21:39 +0000
Create challenge when 2FA is enabled
Diffstat:
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