libeufin

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

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:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 120+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/Tan.kt | 10----------
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 19+++++++++++++------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 42++++++++++++++++--------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 95+++++++++++++++++++++++++++++++++++++------------------------------------------
Mbank/src/test/kotlin/helpers.kt | 16++++++++++------
Mbank/src/test/kotlin/routines.kt | 40++++++++++++++++++++++++++++++++++++++--
Mdatabase-versioning/libeufin-bank-0002.sql | 2+-
Mdatabase-versioning/libeufin-bank-procedures.sql | 41++++++++++++++++++++++++++++-------------
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';