commit dd499d21b388f10e20e476d655f899d22f8c50f9
parent d8709085d570041b82737a7d6c05694e82a267a3
Author: Antoine A <>
Date: Fri, 29 Dec 2023 16:15:19 +0000
Run protected operation when solving the challenge and protect account deletion
Diffstat:
11 files changed, 224 insertions(+), 167 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -28,6 +28,7 @@ import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
import kotlin.random.Random
+import kotlinx.serialization.json.Json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.await
import kotlinx.coroutines.withContext
@@ -221,6 +222,59 @@ suspend fun patchAccount(db: Database, ctx: BankConfig, req: AccountReconfigurat
)
}
+suspend fun ApplicationCall.patchAccountHttp(db: Database, ctx: BankConfig, req: AccountReconfiguration, is2fa: Boolean) {
+ val res = patchAccount(db, ctx, req, username, isAdmin, is2fa)
+ when (res) {
+ AccountPatchResult.Success -> respond(HttpStatusCode.NoContent)
+ AccountPatchResult.TanRequired -> respondChallenge(db, Operation.account_reconfig, req)
+ AccountPatchResult.UnknownAccount -> throw unknownAccount(username)
+ AccountPatchResult.NonAdminName -> throw conflict(
+ "non-admin user cannot change their legal name",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
+ )
+ AccountPatchResult.NonAdminCashout -> throw conflict(
+ "non-admin user cannot change their cashout account",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT
+ )
+ AccountPatchResult.NonAdminDebtLimit -> throw conflict(
+ "non-admin user cannot change their debt limit",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
+ )
+ AccountPatchResult.NonAdminContact -> throw conflict(
+ "non-admin user cannot change their contact info",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
+ )
+ AccountPatchResult.MissingTanInfo -> throw conflict(
+ "missing info for tan channel ${req.tan_channel.get()}",
+ TalerErrorCode.BANK_MISSING_TAN_INFO
+ )
+ }
+}
+
+suspend fun ApplicationCall.deleteAccountHttp(db: Database, ctx: BankConfig, is2fa: Boolean) {
+ // Not deleting reserved names.
+ if (RESERVED_ACCOUNTS.contains(username))
+ throw conflict(
+ "Cannot delete reserved accounts",
+ TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
+ )
+ if (username == "exchange" && ctx.allowConversion)
+ throw conflict(
+ "Cannot delete 'exchange' accounts when conversion is enabled",
+ TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
+ )
+
+ when (db.account.delete(username, isAdmin || is2fa)) {
+ AccountDeletionResult.UnknownAccount -> throw unknownAccount(username)
+ AccountDeletionResult.BalanceNotZero -> throw conflict(
+ "Account balance is not zero.",
+ TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
+ )
+ AccountDeletionResult.TanRequired -> respondChallenge(db, Operation.account_delete, Unit)
+ AccountDeletionResult.Success -> respond(HttpStatusCode.NoContent)
+ }
+}
+
private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
authAdmin(db, TokenScope.readwrite, !ctx.allowRegistration) {
post("/accounts") {
@@ -250,57 +304,13 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
requireAdmin = !ctx.allowAccountDeletion
) {
delete("/accounts/{USERNAME}") {
- // Not deleting reserved names.
- if (RESERVED_ACCOUNTS.contains(username))
- throw conflict(
- "Cannot delete reserved accounts",
- TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
- )
- if (username == "exchange" && ctx.allowConversion)
- throw conflict(
- "Cannot delete 'exchange' accounts when conversion is enabled",
- TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
- )
-
- when (db.account.delete(username)) {
- AccountDeletionResult.UnknownAccount -> throw unknownAccount(username)
- AccountDeletionResult.BalanceNotZero -> throw conflict(
- "Account balance is not zero.",
- TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
- )
- AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
+ call.deleteAccountHttp(db, ctx, false)
}
}
auth(db, TokenScope.readwrite, allowAdmin = true) {
patch("/accounts/{USERNAME}") {
- val (req, is2fa) = call.receiveChallenge<AccountReconfiguration>(db)
- val res = patchAccount(db, ctx, req, username, isAdmin, is2fa)
- when (res) {
- AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
- AccountPatchResult.TanRequired -> call.respondChallenge(db, Operation.account_reconfig, req)
- AccountPatchResult.UnknownAccount -> throw unknownAccount(username)
- AccountPatchResult.NonAdminName -> throw conflict(
- "non-admin user cannot change their legal name",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
- )
- AccountPatchResult.NonAdminCashout -> throw conflict(
- "non-admin user cannot change their cashout account",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT
- )
- AccountPatchResult.NonAdminDebtLimit -> throw conflict(
- "non-admin user cannot change their debt limit",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
- )
- AccountPatchResult.NonAdminContact -> throw conflict(
- "non-admin user cannot change their contact info",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
- )
- AccountPatchResult.MissingTanInfo -> throw conflict(
- "missing info for tan channel ${req.tan_channel.get()}",
- TalerErrorCode.BANK_MISSING_TAN_INFO
- )
- }
+ val req = call.receive<AccountReconfiguration>()
+ call.patchAccountHttp(db, ctx, req, false)
}
patch("/accounts/{USERNAME}/auth") {
val req = call.receive<AccountPasswordChange>()
@@ -751,7 +761,7 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
when (res) {
TanSolveResult.NotFound -> throw notFound(
"Challenge $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND // TODO specific EC
)
TanSolveResult.BadCode -> throw conflict(
"Incorrect TAN code",
@@ -762,11 +772,19 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
"Too many failed confirmation attempt",
TalerErrorCode.BANK_TAN_RATE_LIMITED
)
- TanSolveResult.Expired -> throw conflict( // TODO
+ TanSolveResult.Expired -> throw conflict(
"Challenge expired",
- TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
+ TalerErrorCode.BANK_TAN_CHALLENGE_FAILED // TODO specific EC
)
- TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent)
+ is TanSolveResult.Success -> when (res.op) {
+ Operation.account_reconfig -> {
+ val req = Json.decodeFromString<AccountReconfiguration>(res.body);
+ call.patchAccountHttp(db, ctx, req, true)
+ }
+ Operation.account_delete -> {
+ call.deleteAccountHttp(db, ctx, true)
+ }
+ }
}
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -140,10 +140,10 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
}
install(StatusPages) {
exception<Exception> { call, cause ->
- logger.debug("request failed", cause)
when (cause) {
is LibeufinException -> call.err(cause)
is SQLException -> {
+ logger.debug("request failed", cause)
when (cause.sqlState) {
PSQLState.SERIALIZATION_FAILURE.state -> call.err(
HttpStatusCode.InternalServerError,
@@ -190,6 +190,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
)
}
else -> {
+ logger.debug("request failed", cause)
call.err(
HttpStatusCode.InternalServerError,
cause.message,
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -73,7 +73,8 @@ enum class Timeframe {
}
enum class Operation {
- account_reconfig
+ account_reconfig,
+ account_delete
}
@Serializable(with = Option.Serializer::class)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
@@ -47,16 +47,6 @@ inline suspend fun <reified B> ApplicationCall.respondChallenge(db: Database, op
)
}
-inline suspend fun <reified B> ApplicationCall.receiveChallenge(db: Database): Pair<B, Boolean> {
- val challengeId: Long? = request.headers.get("TODO")?.run { toLongOrNull() } // TODO Handle not long
- return if (challengeId != null) {
- val body = db.tan.body(challengeId, username) ?: throw Exception("TODO")
- Pair(Json.decodeFromString<B>(body), true)
- } else {
- Pair(receive(), false)
- }
-}
-
object Tan {
private val CODE_FORMAT = DecimalFormat("00000000");
private val SECURE_RNG = SecureRandom()
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -172,23 +172,30 @@ class AccountDAO(private val db: Database) {
enum class AccountDeletionResult {
Success,
UnknownAccount,
- BalanceNotZero
+ BalanceNotZero,
+ TanRequired
}
/** Delete account [login] */
- suspend fun delete(login: String): AccountDeletionResult = db.serializable { conn ->
+ suspend fun delete(
+ login: String,
+ is2fa: Boolean
+ ): AccountDeletionResult = db.serializable { conn ->
val stmt = conn.prepareStatement("""
SELECT
- out_nx_customer,
- out_balance_not_zero
- FROM customer_delete(?);
+ out_not_found,
+ out_balance_not_zero,
+ out_tan_required
+ FROM account_delete(?,?);
""")
stmt.setString(1, login)
+ stmt.setBoolean(2, is2fa)
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("Deletion returned nothing.")
- it.getBoolean("out_nx_customer") -> AccountDeletionResult.UnknownAccount
+ it.getBoolean("out_not_found") -> AccountDeletionResult.UnknownAccount
it.getBoolean("out_balance_not_zero") -> AccountDeletionResult.BalanceNotZero
+ it.getBoolean("out_tan_required") -> AccountDeletionResult.TanRequired
else -> AccountDeletionResult.Success
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
@@ -47,7 +47,7 @@ class TanDAO(private val db: Database) {
stmt.setString(7, login)
stmt.oneOrNull {
it.getLong(1)
- }!! // TODO handle database weirdness
+ } ?: throw internalServerError("TAN challenge returned nothing.")
}
/** Result of TAN challenge transmission */
@@ -99,12 +99,12 @@ class TanDAO(private val db: Database) {
}
/** Result of TAN challenge solution */
- enum class TanSolveResult {
- Success,
- NotFound,
- NoRetry,
- Expired,
- BadCode
+ sealed class TanSolveResult {
+ data class Success(val body: String, val op: Operation): TanSolveResult()
+ data object NotFound: TanSolveResult()
+ data object NoRetry: TanSolveResult()
+ data object Expired: TanSolveResult()
+ data object BadCode: TanSolveResult()
}
/** Solve TAN challenge */
@@ -114,7 +114,11 @@ class TanDAO(private val db: Database) {
code: String,
now: Instant
) = db.serializable { conn ->
- val stmt = conn.prepareStatement("SELECT out_ok, out_no_op, out_no_retry, out_expired FROM tan_challenge_try(?,?,?,?)")
+ val stmt = conn.prepareStatement("""
+ SELECT
+ out_ok, out_no_op, out_no_retry, out_expired,
+ out_body, out_op
+ FROM tan_challenge_try(?,?,?,?)""")
stmt.setLong(1, id)
stmt.setString(2, login)
stmt.setString(3, code)
@@ -122,7 +126,10 @@ class TanDAO(private val db: Database) {
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("TAN try returned nothing")
- it.getBoolean("out_ok") -> TanSolveResult.Success
+ it.getBoolean("out_ok") -> TanSolveResult.Success(
+ body = it.getString("out_body"),
+ op = Operation.valueOf(it.getString("out_op"))
+ )
it.getBoolean("out_no_op") -> TanSolveResult.NotFound
it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry
it.getBoolean("out_expired") -> TanSolveResult.Expired
@@ -130,21 +137,4 @@ class TanDAO(private val db: Database) {
}
}
}
-
- /** Get body of a solved TAN challenge */
- suspend fun body(
- id: Long,
- login: String
- ) = db.conn { conn ->
- val stmt = conn.prepareStatement("""
- SELECT body
- FROM tan_challenges JOIN customers ON customer=customer_id
- WHERE challenge_id=? AND login=? AND confirmation_date IS NOT NULL
- """)
- stmt.setLong(1, id)
- stmt.setString(2, login)
- stmt.oneOrNull {
- it.getString(1)
- }
- }
}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -319,10 +319,7 @@ class CoreBankAccountsApiTest {
// DELETE /accounts/USERNAME
@Test
fun delete() = bankSetup { _ ->
- // Unknown account
- client.delete("/accounts/unknown") {
- pwAuth("admin")
- }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+ authRoutine(HttpMethod.Delete, "/accounts/merchant", allowAdmin = true)
// Reserved account
RESERVED_ACCOUNTS.forEach {
@@ -332,31 +329,31 @@ class CoreBankAccountsApiTest {
}
client.deleteA("/accounts/exchange")
.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)
-
- // successful deletion
- client.post("/accounts") {
- json {
- "username" to "john"
- "password" to "password"
- "name" to "John Smith"
- }
- }.assertOk()
- client.delete("/accounts/john") {
- pwAuth("admin")
- }.assertNoContent()
- // Trying again must yield 404
- client.delete("/accounts/john") {
- pwAuth("admin")
- }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
-
- // fail to delete, due to a non-zero balance.
- tx("customer", "KUDOS:1", "merchant")
- client.deleteA("/accounts/merchant")
- .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
- tx("merchant", "KUDOS:1", "customer")
- client.deleteA("/accounts/merchant")
- .assertNoContent()
+ tanRoutine("john", prepare = {
+ client.post("/accounts") {
+ json {
+ "username" to "john"
+ "password" to "john-password"
+ "name" to "John"
+ "internal_payto_uri" to genTmpPayTo()
+ }
+ }.assertOk()
+ }) { challenge ->
+ // Fail to delete, due to a non-zero balance.
+ tx("customer", "KUDOS:1", "john")
+ client.deleteA("/accounts/john")
+ .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
+ // Sucessful deletion
+ tx("john", "KUDOS:1", "customer")
+ client.deleteA("/accounts/john")
+ .challenge()
+ .assertNoContent()
+ // Account no longer exists
+ client.delete("/accounts/john") {
+ pwAuth("admin")
+ }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+ }
}
// Test admin-only account deletion
@@ -398,7 +395,7 @@ class CoreBankAccountsApiTest {
// PATCH /accounts/USERNAME
@Test
fun reconfig() = bankSetup { _ ->
- authRoutine(HttpMethod.Patch, "/accounts/merchant", withAdmin = true)
+ authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true)
// Successful attempt now
val cashout = IbanPayTo(genIbanPaytoUri())
@@ -474,25 +471,18 @@ class CoreBankAccountsApiTest {
}.assertConflict(TalerErrorCode.END)
// Check 2FA
- client.patchA("/accounts/merchant") {
- json { "tan_channel" to "sms" }
- }.assertNoContent()
- val challengeId = client.patchA("/accounts/merchant") {
- json { "is_public" to false }
- }.assertAcceptedJson<TanChallenge>().challenge_id;
- client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
- // Check request patch did not happen
- assert(obj.is_public)
- }
- client.postA("/accounts/merchant/challenge/$challengeId").assertOk()
- client.postA("/accounts/customer/challenge/$challengeId/confirm") {
- json { "tan" to smsCode("+99") }
- }.assertNoContent()
- client.patchA("/accounts/merchant") {
- header("TODO", "$challengeId")
- }.assertNoContent()
- client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
- assert(!obj.is_public)
+ tanRoutine("merchant", prepare = {
+ client.patch("/accounts/merchant") {
+ pwAuth("admin")
+ json { "is_public" to true }
+ }
+ }) { challenge ->
+ val challengeId = client.patchA("/accounts/merchant") {
+ json { "is_public" to false }
+ }.challenge()
+ client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
+ assert(!obj.is_public)
+ }
}
}
@@ -534,7 +524,7 @@ class CoreBankAccountsApiTest {
// PATCH /accounts/USERNAME/auth
@Test
fun passwordChange() = bankSetup { _ ->
- authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", withAdmin = true)
+ authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = true)
// Changing the password.
client.patch("/accounts/customer/auth") {
@@ -649,7 +639,7 @@ class CoreBankAccountsApiTest {
// GET /accounts/USERNAME
@Test
fun get() = bankSetup { _ ->
- authRoutine(HttpMethod.Get, "/accounts/merchant", withAdmin = true)
+ authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true)
// Check ok
client.getA("/accounts/merchant").assertOkJson<AccountData> {
assertEquals("Merchant", it.name)
@@ -1446,6 +1436,11 @@ class CoreBankTanApiTest {
client.postA("/accounts/merchant/challenge/$id/confirm") {
json { "tan" to "nice-try" }
}.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
+
+ // Check wrong account
+ client.postA("/accounts/customer/challenge/$id/confirm") {
+ json { "tan" to "nice-try" }
+ }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
// Check OK
client.postA("/accounts/merchant/challenge/$id/confirm") {
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -39,13 +39,19 @@ import tech.libeufin.util.*
val merchantPayto = IbanPayTo(genIbanPaytoUri())
val exchangePayto = IbanPayTo(genIbanPaytoUri())
val customerPayto = IbanPayTo(genIbanPaytoUri())
-val unknownPayto = IbanPayTo(genIbanPaytoUri())
+val unknownPayto = IbanPayTo(genIbanPaytoUri())
+var tmpPayTo = IbanPayTo(genIbanPaytoUri())
val paytos = mapOf(
"merchant" to merchantPayto,
"exchange" to exchangePayto,
"customer" to customerPayto
)
+fun genTmpPayTo(): IbanPayTo {
+ tmpPayTo = IbanPayTo(genIbanPaytoUri())
+ return tmpPayTo
+}
+
fun setup(
conf: String = "test.conf",
lambda: suspend (Database, BankConfig) -> Unit
@@ -164,18 +170,16 @@ suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String
/** Perform a bank transaction of [amount] [from] account [to] account with [subject} */
suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long {
- return client.post("/accounts/$from/transactions") {
- basicAuth("$from", "$from-password")
+ return client.postA("/accounts/$from/transactions") {
json {
- "payto_uri" to "${paytos[to]}?message=${subject.encodeURLQueryComponent()}&amount=$amount"
+ "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLQueryComponent()}&amount=$amount"
}
}.assertOkJson<TransactionCreateResponse>().row_id
}
/** Perform a taler outgoing transaction of [amount] from exchange to merchant */
suspend fun ApplicationTestBuilder.transfer(amount: String) {
- client.post("/accounts/exchange/taler-wire-gateway/transfer") {
- pwAuth("exchange")
+ client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
json {
"request_uid" to randHashCode()
"amount" to TalerAmount(amount)
diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt
@@ -35,7 +35,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
body: JsonObject? = null,
requireExchange: Boolean = false,
requireAdmin: Boolean = false,
- withAdmin: Boolean = false
+ allowAdmin: Boolean = false
) {
// No body when authentication must happen before parsing the body
@@ -63,7 +63,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
this.method = method
pwAuth("merchant")
}.assertUnauthorized()
- } else if (!withAdmin) {
+ } else if (!allowAdmin) {
// Check no admin
client.request(path) {
this.method = method
@@ -81,6 +81,42 @@ suspend fun ApplicationTestBuilder.authRoutine(
}
}
+// Test endpoint is correctly protected using 2fa
+suspend fun ApplicationTestBuilder.tanRoutine(
+ username: String,
+ prepare: suspend () -> Unit = {},
+ routine: suspend (suspend HttpResponse.() -> HttpResponse) -> Unit,
+) {
+ // Check without 2FA
+ prepare()
+ client.patch("/accounts/$username") {
+ pwAuth("admin")
+ json {
+ "tan_channel" to null as Int?
+ }
+ }.assertNoContent()
+ routine({ this })
+
+ // Check with 2FA
+ prepare()
+ client.patch("/accounts/$username") {
+ pwAuth("admin")
+ json {
+ "contact_data" to obj {
+ "phone" to "+42"
+ }
+ "tan_channel" to "sms"
+ }
+ }.assertNoContent()
+ routine({
+ val id = this.assertAcceptedJson<TanChallenge>().challenge_id
+ client.postA("/accounts/$username/challenge/$id").assertOk()
+ client.postA("/accounts/$username/challenge/$id/confirm") {
+ json { "tan" to smsCode("+42") }
+ }
+ })
+}
+
inline suspend fun <reified B> ApplicationTestBuilder.historyRoutine(
url: String,
crossinline ids: (B) -> List<Long>,
diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql
@@ -25,7 +25,7 @@ ALTER TABLE customers
ADD tan_channel tan_enum NULL;
CREATE TYPE op_enum
- AS ENUM ('account_reconfig');
+ AS ENUM ('account_reconfig', 'account_delete');
CREATE TABLE tan_challenges
(challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -140,10 +140,12 @@ END IF;
END $$;
COMMENT ON FUNCTION account_balance_is_sufficient IS 'Check if an account have enough fund to transfer an amount.';
-CREATE FUNCTION customer_delete(
+CREATE FUNCTION account_delete(
IN in_login TEXT,
- OUT out_nx_customer BOOLEAN,
- OUT out_balance_not_zero BOOLEAN
+ IN in_is_tan BOOLEAN,
+ OUT out_not_found BOOLEAN,
+ OUT out_balance_not_zero BOOLEAN,
+ OUT out_tan_required BOOLEAN
)
LANGUAGE plpgsql AS $$
DECLARE
@@ -152,15 +154,14 @@ my_balance_val INT8;
my_balance_frac INT4;
BEGIN
-- check if login exists
-SELECT customer_id
- INTO my_customer_id
+SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL)
+ INTO my_customer_id, out_tan_required
FROM customers
WHERE login = in_login;
IF NOT FOUND THEN
- out_nx_customer=TRUE;
+ out_not_found=TRUE;
RETURN;
END IF;
-out_nx_customer=FALSE;
-- get the balance
SELECT
@@ -174,18 +175,22 @@ SELECT
IF NOT FOUND THEN
RAISE EXCEPTION 'Invariant failed: customer lacks bank account';
END IF;
+
-- check that balance is zero.
IF my_balance_val != 0 OR my_balance_frac != 0 THEN
out_balance_not_zero=TRUE;
RETURN;
END IF;
-out_balance_not_zero=FALSE;
+
+-- check tan required
+IF out_tan_required THEN
+ RETURN;
+END IF;
-- actual deletion
DELETE FROM customers WHERE login = in_login;
END $$;
-COMMENT ON FUNCTION customer_delete(TEXT)
- IS 'Deletes a customer (and its bank account via cascade) if the balance is zero';
+COMMENT ON FUNCTION account_delete IS 'Deletes an account if the balance is zero';
CREATE PROCEDURE register_outgoing(
IN in_request_uid BYTEA,
@@ -1416,11 +1421,15 @@ CREATE FUNCTION tan_challenge_try (
IN in_challenge_id BIGINT,
IN in_login TEXT,
IN in_code TEXT,
- IN in_now_date INT8,
+ IN in_now_date INT8,
+ -- Error status
OUT out_ok BOOLEAN,
OUT out_no_op BOOLEAN,
OUT out_no_retry BOOLEAN,
- OUT out_expired BOOLEAN
+ OUT out_expired BOOLEAN,
+ -- Success return
+ OUT out_op op_enum,
+ OUT out_body TEXT
)
LANGUAGE plpgsql as $$
DECLARE
@@ -1435,7 +1444,7 @@ UPDATE tan_challenges SET
ELSE confirmation_date
END,
retry_counter = retry_counter - 1
-WHERE challenge_id = in_challenge_id
+WHERE challenge_id = in_challenge_id AND customer = account_id
RETURNING
confirmation_date IS NOT NULL,
retry_counter <= 0 AND confirmation_date IS NULL,
@@ -1444,7 +1453,13 @@ INTO out_ok, out_no_retry, out_expired;
IF NOT FOUND THEN
out_no_op = true;
RETURN;
+ELSIF NOT out_ok OR out_no_retry OR out_expired THEN
+ RETURN;
END IF;
+
+-- Recover body and op from challenge
+SELECT body, op INTO out_body, out_op
+ FROM tan_challenges WHERE challenge_id = in_challenge_id;
END $$;
COMMENT ON FUNCTION tan_challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed';