diff options
author | Antoine A <> | 2024-01-02 18:06:17 +0000 |
---|---|---|
committer | Antoine A <> | 2024-01-02 18:06:17 +0000 |
commit | 70ce1cf9fb377540fe0ae3241b184651f3c23b67 (patch) | |
tree | 3a050b26192f7d5261483dff1afce1b8b8f61696 | |
parent | 741dba08455b6456c2d8186308e6da09efa1a029 (diff) | |
download | libeufin-70ce1cf9fb377540fe0ae3241b184651f3c23b67.tar.gz libeufin-70ce1cf9fb377540fe0ae3241b184651f3c23b67.tar.bz2 libeufin-70ce1cf9fb377540fe0ae3241b184651f3c23b67.zip |
Check new tan parameters using a 2FA challenge when an user modifies them
-rw-r--r-- | bank/conf/test.conf | 2 | ||||
-rw-r--r-- | bank/conf/test_tan_err.conf (renamed from bank/conf/test_no_tan.conf) | 2 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 34 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Tan.kt | 12 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 80 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 18 | ||||
-rw-r--r-- | bank/src/test/kotlin/CoreBankApiTest.kt | 303 | ||||
-rw-r--r-- | bank/src/test/kotlin/helpers.kt | 39 | ||||
-rw-r--r-- | database-versioning/libeufin-bank-procedures.sql | 17 |
10 files changed, 347 insertions, 163 deletions
diff --git a/bank/conf/test.conf b/bank/conf/test.conf index b4eb3953..4a7a4476 100644 --- a/bank/conf/test.conf +++ b/bank/conf/test.conf @@ -8,7 +8,7 @@ ALLOW_EDIT_CASHOUT_PAYTO_URI = yes allow_conversion = YES FIAT_CURRENCY = EUR tan_sms = libeufin-tan-file.sh -tan_email = libeufin-tan-fail.sh +tan_email = libeufin-tan-file.sh [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/conf/test_no_tan.conf b/bank/conf/test_tan_err.conf index 52e824b2..faaf9883 100644 --- a/bank/conf/test_no_tan.conf +++ b/bank/conf/test_tan_err.conf @@ -6,6 +6,8 @@ FIAT_CURRENCY = EUR ALLOW_REGISTRATION = yes ALLOW_ACCOUNT_DELETION = yes ALLOW_EDIT_CASHOUT_PAYTO_URI = yes +tan_sms = libeufin-tan-fail.sh +tan_email = [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt index 17587d5f..ea71cd40 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -192,7 +192,16 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq } } -suspend fun patchAccount(db: Database, ctx: BankConfig, req: AccountReconfiguration, username: String, isAdmin: Boolean, is2fa: Boolean): AccountPatchResult { +suspend fun patchAccount( + db: Database, + ctx: BankConfig, + req: AccountReconfiguration, + username: String, + isAdmin: Boolean, + is2fa: Boolean, + channel: TanChannel? = null, + info: String? = null +): AccountPatchResult { req.debit_threshold?.run { ctx.checkRegionalCurrency(this) } val contactData = req.contact_data ?: req.challenge_contact_data @@ -217,16 +226,27 @@ suspend fun patchAccount(db: Database, ctx: BankConfig, req: AccountReconfigurat debtLimit = req.debit_threshold, isAdmin = isAdmin, is2fa = is2fa, + faChannel = channel, + faInfo = info, allowEditName = ctx.allowEditName, allowEditCashout = ctx.allowEditCashout ) } -suspend fun ApplicationCall.patchAccountHttp(db: Database, ctx: BankConfig, req: AccountReconfiguration, is2fa: Boolean) { - val res = patchAccount(db, ctx, req, username, isAdmin, is2fa) +suspend fun ApplicationCall.patchAccountHttp( + db: Database, + ctx: BankConfig, + req: AccountReconfiguration, + is2fa: Boolean, + channel: TanChannel? = null, + info: String? = null +) { + val res = patchAccount(db, ctx, req, username, isAdmin, is2fa, channel, info) when (res) { AccountPatchResult.Success -> respond(HttpStatusCode.NoContent) - AccountPatchResult.TanRequired -> respondChallenge(db, Operation.account_reconfig, req) + is AccountPatchResult.TanRequired -> { + respondChallenge(db, Operation.account_reconfig, req, res.channel, res.info) + } AccountPatchResult.UnknownAccount -> throw unknownAccount(username) AccountPatchResult.NonAdminName -> throw conflict( "non-admin user cannot change their legal name", @@ -240,10 +260,6 @@ suspend fun ApplicationCall.patchAccountHttp(db: Database, ctx: BankConfig, req: "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 @@ -779,7 +795,7 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { is TanSolveResult.Success -> when (res.op) { Operation.account_reconfig -> { val req = Json.decodeFromString<AccountReconfiguration>(res.body); - call.patchAccountHttp(db, ctx, req, true) + call.patchAccountHttp(db, ctx, req, true, res.channel, res.info) } 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 index 483a25cf..bfe30d96 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -398,8 +398,7 @@ class EditAccount : CliktCommand( AccountPatchResult.NonAdminName, AccountPatchResult.NonAdminCashout, AccountPatchResult.NonAdminDebtLimit, - AccountPatchResult.NonAdminContact, - AccountPatchResult.TanRequired -> { + is AccountPatchResult.TanRequired -> { // Unreachable as we edit account as admin } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt index 63b40a6b..a366ce13 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt @@ -29,7 +29,13 @@ import io.ktor.server.response.* import io.ktor.server.application.* -inline suspend fun <reified B> ApplicationCall.respondChallenge(db: Database, op: Operation, body: B) { +inline suspend fun <reified B> ApplicationCall.respondChallenge( + db: Database, + op: Operation, + body: B, + channel: TanChannel? = null, + info: String? = null +) { val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), body); val code = Tan.genCode() val id = db.tan.new( @@ -39,7 +45,9 @@ inline suspend fun <reified B> ApplicationCall.respondChallenge(db: Database, op code = code, now = Instant.now(), retryCounter = TAN_RETRY_COUNTER, - validityPeriod = TAN_VALIDITY_PERIOD + validityPeriod = TAN_VALIDITY_PERIOD, + channel = channel, + info = info ) respond( status = HttpStatusCode.Accepted, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt index 2e702b66..52c207f7 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -202,15 +202,14 @@ class AccountDAO(private val db: Database) { } /** Result status of customer account patch */ - enum class AccountPatchResult { - UnknownAccount, - NonAdminName, - NonAdminCashout, - NonAdminDebtLimit, - NonAdminContact, - MissingTanInfo, - TanRequired, - Success + sealed class AccountPatchResult { + data object UnknownAccount: AccountPatchResult() + data object NonAdminName: AccountPatchResult() + data object NonAdminCashout: AccountPatchResult() + data object NonAdminDebtLimit: AccountPatchResult() + data object MissingTanInfo: AccountPatchResult() + data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult() + data object Success: AccountPatchResult() } /** Change account [login] informations */ @@ -225,30 +224,28 @@ class AccountDAO(private val db: Database) { debtLimit: TalerAmount?, isAdmin: Boolean, is2fa: Boolean, + faChannel: TanChannel?, + faInfo: String?, allowEditName: Boolean, allowEditCashout: Boolean, ): AccountPatchResult = db.serializable { it.transaction { conn -> val checkName = !isAdmin && !allowEditName && name != null val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome() val checkDebtLimit = !isAdmin && debtLimit != null - val checkPhone = !isAdmin && phone.isSome() - val checkEmail = !isAdmin && email.isSome() // Get user ID and check reconfig rights - val customer_id = conn.prepareStatement(""" + val (customerId, currChannel, currInfo) = conn.prepareStatement(""" SELECT customer_id ,(${ if (checkName) "name != ?" else "false" }) as name_change ,(${ if (checkCashout) "cashout_payto IS DISTINCT FROM ?" else "false" }) as cashout_change ,(${ if (checkDebtLimit) "max_debt != (?, ?)::taler_amount" else "false" }) as debt_limit_change - ,(${ if (checkPhone) "phone IS DISTINCT FROM ?" else "false" }) as phone_change - ,(${ if (checkEmail) "email IS DISTINCT FROM ?" else "false" }) as email_change ,(${ when (tan_channel.get()) { null -> "false" 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 + ,tan_channel, phone, email FROM customers JOIN bank_accounts ON customer_id=owning_customer_id @@ -265,12 +262,6 @@ class AccountDAO(private val db: Database) { setLong(idx, debtLimit!!.value); idx++ setInt(idx, debtLimit.frac); idx++ } - if (checkPhone) { - setString(idx, phone.get()); idx++ - } - if (checkEmail) { - setString(idx, email.get()); idx++ - } setString(idx, login) executeQuery().use { when { @@ -278,14 +269,49 @@ class AccountDAO(private val db: Database) { it.getBoolean("name_change") -> return@transaction AccountPatchResult.NonAdminName it.getBoolean("cashout_change") -> return@transaction AccountPatchResult.NonAdminCashout it.getBoolean("debt_limit_change") -> return@transaction AccountPatchResult.NonAdminDebtLimit - 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") && !is2fa && !isAdmin -> return@transaction AccountPatchResult.TanRequired - else -> it.getLong("customer_id") + else -> { + val currChannel = it.getString("tan_channel")?.run { TanChannel.valueOf(this) } + Triple( + it.getLong("customer_id"), + currChannel, + when (tan_channel.get() ?: currChannel) { + TanChannel.sms -> it.getString("phone") + TanChannel.email -> it.getString("email") + null -> null + } + ) + } } } } + + val newChannel = tan_channel.get(); + val newInfo = when (newChannel ?: currChannel) { + TanChannel.sms -> phone.get() + TanChannel.email -> email.get() + null -> null + } + + // Tan channel verification + if (!isAdmin) { + // Check performed 2fa check + if (currChannel != null && !is2fa) { + // Perform challenge with current settings + return@transaction AccountPatchResult.TanRequired(channel = null, info = null) + } + // If channel or info changed and the 2fa challenge is performed with old settings perform a new challenge with new settings + if ((newChannel != null && newChannel != faChannel) || (newInfo != null && newInfo != faInfo)) { + return@transaction AccountPatchResult.TanRequired(channel = newChannel ?: currChannel, info = newInfo ?: currInfo) + } + } + + // Invalidate current challenges + if (newChannel != null || newInfo != null) { + val stmt = conn.prepareStatement("UPDATE tan_challenges SET expiration_date=0 WHERE customer=?") + stmt.setLong(1, customerId) + stmt.execute() + } // Update bank info conn.dynamicUpdate( @@ -298,7 +324,7 @@ class AccountDAO(private val db: Database) { sequence { isPublic?.let { yield(it) } debtLimit?.let { yield(it.value); yield(it.frac) } - yield(customer_id) + yield(customerId) } ) @@ -319,7 +345,7 @@ class AccountDAO(private val db: Database) { email.some { yield(it) } tan_channel.some { yield(it?.name) } name?.let { yield(it) } - yield(customer_id) + yield(customerId) } ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt index 3dc22ab2..631329ce 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -35,9 +35,11 @@ class TanDAO(private val db: Database) { code: String, now: Instant, retryCounter: Int, - validityPeriod: Duration + validityPeriod: Duration, + channel: TanChannel? = null, + info: String? = null ): Long = db.serializable { conn -> - val stmt = conn.prepareStatement("SELECT tan_challenge_create(?, ?::op_enum, ?, ?, ?, ?, ?, NULL, NULL)") + val stmt = conn.prepareStatement("SELECT tan_challenge_create(?,?::op_enum,?,?,?,?,?,?::tan_enum,?)") stmt.setString(1, body) stmt.setString(2, op.name) stmt.setString(3, code) @@ -45,6 +47,8 @@ class TanDAO(private val db: Database) { stmt.setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod)) stmt.setInt(6, retryCounter) stmt.setString(7, login) + stmt.setString(8, channel?.name) + stmt.setString(9, info) stmt.oneOrNull { it.getLong(1) } ?: throw internalServerError("TAN challenge returned nothing.") @@ -76,7 +80,7 @@ class TanDAO(private val db: Database) { when { !it.next() -> throw internalServerError("TAN send returned nothing.") it.getBoolean("out_no_op") -> TanSendResult.NotFound - else -> TanSendResult.Success( + else -> TanSendResult.Success( tanInfo = it.getString("out_tan_info"), tanChannel = it.getString("out_tan_channel").run { TanChannel.valueOf(this) }, tanCode = it.getString("out_tan_code") @@ -100,7 +104,7 @@ class TanDAO(private val db: Database) { /** Result of TAN challenge solution */ sealed class TanSolveResult { - data class Success(val body: String, val op: Operation): TanSolveResult() + data class Success(val body: String, val op: Operation, val channel: TanChannel?, val info: String?): TanSolveResult() data object NotFound: TanSolveResult() data object NoRetry: TanSolveResult() data object Expired: TanSolveResult() @@ -117,7 +121,7 @@ class TanDAO(private val db: Database) { val stmt = conn.prepareStatement(""" SELECT out_ok, out_no_op, out_no_retry, out_expired, - out_body, out_op + out_body, out_op, out_channel, out_info FROM tan_challenge_try(?,?,?,?)""") stmt.setLong(1, id) stmt.setString(2, login) @@ -128,7 +132,9 @@ class TanDAO(private val db: Database) { !it.next() -> throw internalServerError("TAN try returned nothing") it.getBoolean("out_ok") -> TanSolveResult.Success( body = it.getString("out_body"), - op = Operation.valueOf(it.getString("out_op")) + op = Operation.valueOf(it.getString("out_op")), + channel = it.getString("out_channel")?.run { TanChannel.valueOf(this) }, + info = it.getString("out_info") ) it.getBoolean("out_no_op") -> TanSolveResult.NotFound it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt index ff2548e2..3d95096a 100644 --- a/bank/src/test/kotlin/CoreBankApiTest.kt +++ b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -395,12 +395,23 @@ class CoreBankAccountsApiTest { fun reconfig() = bankSetup { _ -> authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true) + // Check tan info + for (channel in listOf("sms", "email")) { + client.patchA("/accounts/merchant") { + json { "tan_channel" to channel } + }.assertErr(TalerErrorCode.BANK_MISSING_TAN_INFO) + } + // Successful attempt now val cashout = IbanPayTo(genIbanPaytoUri()) val req = obj { "cashout_payto_uri" to cashout.canonical "name" to "Roger" - "is_public" to true + "is_public" to true + "contact_data" to obj { + "phone" to "+99" + "email" to "foo@example.com" + } } client.patchA("/accounts/merchant") { json(req) @@ -410,25 +421,11 @@ class CoreBankAccountsApiTest { json(req) }.assertNoContent() - // Check tan info - for (channel in listOf("sms", "email")) { - client.patchA("/accounts/merchant") { - json { "tan_channel" to channel } - }.assertErr(TalerErrorCode.BANK_MISSING_TAN_INFO) - } checkAdminOnly( obj(req) { "debit_threshold" to "KUDOS:100" }, TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) - checkAdminOnly( - obj(req) { "contact_data" to obj { "phone" to "+99" } }, - TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT - ) - checkAdminOnly( - obj(req) { "contact_data" to obj { "email" to "foo@example.com" } }, - TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT - ) // Check currency client.patch("/accounts/merchant") { @@ -472,7 +469,7 @@ class CoreBankAccountsApiTest { fillTanInfo("merchant") client.patchA("/accounts/merchant") { json { "is_public" to false } - }.assertChallenge { + }.assertChallenge { _, _ -> client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> assert(obj.is_public) } @@ -508,11 +505,11 @@ class CoreBankAccountsApiTest { // Test TAN check account patch @Test - fun patchNoTan() = bankSetup(conf = "test_no_tan.conf") { _ -> + fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { _ -> // Check unsupported TAN channel client.patchA("/accounts/customer") { json { - "tan_channel" to "sms" + "tan_channel" to "email" } }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED) } @@ -979,24 +976,24 @@ class CoreBankCashoutApiTest { } }.assertNoContent() - // Check email TAN error - client.postA("/accounts/customer/cashouts") { + // Check email TAN error TODO use 2FA + /*client.postA("/accounts/customer/cashouts") { json(req) { "tan_channel" to "email" } - }.assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED) + }.assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)*/ // Check OK client.postA("/accounts/customer/cashouts") { json(req) }.assertOkJson<CashoutPending> { first -> - smsCode("+99") + tanCode("+99") // Check idempotency client.postA("/accounts/customer/cashouts") { json(req) }.assertOkJson<CashoutPending> { second -> assertEquals(first.cashout_id, second.cashout_id) - assertNull(smsCode("+99")) + assertNull(tanCode("+99")) } } @@ -1041,23 +1038,6 @@ class CoreBankCashoutApiTest { }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) } - // POST /accounts/{USERNAME}/cashouts - @Test - fun createNoTan() = bankSetup("test_no_tan.conf") { _ -> - val req = obj { - "request_uid" to randShortHashCode() - "amount_debit" to "KUDOS:1" - "amount_credit" to convert("KUDOS:1") - } - - fillCashoutInfo("customer") - - // Check unsupported TAN channel - client.postA("/accounts/customer/cashouts") { - json(req) - }.assertStatus(HttpStatusCode.NotImplemented, TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED) - } - // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort @Test fun abort() = bankSetup { _ -> @@ -1092,7 +1072,7 @@ class CoreBankCashoutApiTest { val id = it.cashout_id client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to smsCode("+99") } + json { "tan" to tanCode("+99") } }.assertNoContent() // Check error @@ -1159,7 +1139,7 @@ class CoreBankCashoutApiTest { json { "tan" to "nice-try" } }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) - val code = smsCode("+99") + val code = tanCode("+99") // Check OK client.postA("/accounts/customer/cashouts/$id/confirm") { @@ -1208,7 +1188,7 @@ class CoreBankCashoutApiTest { }.assertNoContent() client.postA("/accounts/customer/cashouts/$id/confirm"){ - json { "tan" to smsCode("+99") } + json { "tan" to tanCode("+99") } }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) // Check can abort because not confirmed @@ -1227,7 +1207,7 @@ class CoreBankCashoutApiTest { // Send too much money tx("customer", "KUDOS:9", "merchant") client.postA("/accounts/customer/cashouts/$id/confirm"){ - json { "tan" to smsCode("+99") } + json { "tan" to tanCode("+99") } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Check can abort because not confirmed @@ -1274,7 +1254,7 @@ class CoreBankCashoutApiTest { } client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to smsCode("+99") } + json { "tan" to tanCode("+99") } }.assertNoContent() client.getA("/accounts/customer/cashouts/$id") .assertOkJson<CashoutStatusResponse> { @@ -1355,43 +1335,138 @@ class CoreBankCashoutApiTest { class CoreBankTanApiTest { // POST /accounts/{USERNAME}/challenge/{challenge_id} @Test - fun create() = bankSetup { _ -> + fun send() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42") - client.patch("/accounts/merchant") { - pwAuth("admin") + suspend fun HttpResponse.expectChallenge(channel: TanChannel, info: String): HttpResponse { + return assertChallenge { tanChannel, tanInfo -> + assertEquals(channel, tanChannel) + assertEquals(info, tanInfo) + } + } + + suspend fun HttpResponse.expectTransmission(channel: TanChannel, info: String) { + this.assertOkJson<TanTransmission> { + assertEquals(it.tan_channel, channel) + assertEquals(it.tan_info, info) + } + } + + // Set up 2fa + client.patchA("/accounts/merchant") { json { "contact_data" to obj { "phone" to "+99" + "email" to "email@example.com" } "tan_channel" to "sms" } + }.expectChallenge(TanChannel.sms, "+99") + .assertNoContent() + + // Update 2fa settings - first 2FA challenge then new tan channel check + client.patchA("/accounts/merchant") { + json { // Info change + "contact_data" to obj { "phone" to "+98" } + } + }.expectChallenge(TanChannel.sms, "+99") + .expectChallenge(TanChannel.sms, "+98") + .assertNoContent() + client.patchA("/accounts/merchant") { + json { // Channel change + "tan_channel" to "email" + } + }.expectChallenge(TanChannel.sms, "+98") + .expectChallenge(TanChannel.email, "email@example.com") + .assertNoContent() + client.patchA("/accounts/merchant") { + json { // Both change + "contact_data" to obj { "phone" to "+97" } + "tan_channel" to "sms" + } + }.expectChallenge(TanChannel.email, "email@example.com") + .expectChallenge(TanChannel.sms, "+97") + .assertNoContent() + + // Disable 2fa + client.patchA("/accounts/merchant") { + json { "tan_channel" to null as String? } + }.expectChallenge(TanChannel.sms, "+97") + .assertNoContent() + + // Admin has no 2FA + client.patch("/accounts/merchant") { + pwAuth("admin") + json { + "contact_data" to obj { "phone" to "+99" } + "tan_channel" to "sms" + } + }.assertNoContent() + client.patch("/accounts/merchant") { + pwAuth("admin") + json { "tan_channel" to "email" } }.assertNoContent() + client.patch("/accounts/merchant") { + pwAuth("admin") + json { "tan_channel" to null as String? } + }.assertNoContent() + + // Check retry and invalidate + client.patchA("/accounts/merchant") { + json { + "contact_data" to obj { "phone" to "+88" } + "tan_channel" to "sms" + } + }.assertChallenge().assertNoContent() client.patchA("/accounts/merchant") { json { "is_public" to false } }.assertAcceptedJson<TanChallenge> { // Check ok client.postA("/accounts/merchant/challenge/${it.challenge_id}") - .assertOkJson<TanTransmission> { - assertEquals(it.tan_info, "+99") - assertEquals(it.tan_channel.name, "sms") - } + .expectTransmission(TanChannel.sms, "+88") + assertNotNull(tanCode("+88")) // Check retry client.postA("/accounts/merchant/challenge/${it.challenge_id}") - .assertOkJson<TanTransmission> { - assertEquals(it.tan_info, "+99") - assertEquals(it.tan_channel.name, "sms") + .expectTransmission(TanChannel.sms, "+88") + assertNull(tanCode("+88")) + // Idempotent patch does nothing + client.patchA("/accounts/merchant") { + json { + "contact_data" to obj { "phone" to "+88" } + "tan_channel" to "sms" + } + } + client.postA("/accounts/merchant/challenge/${it.challenge_id}") + .expectTransmission(TanChannel.sms, "+88") + assertNull(tanCode("+88")) + // Change 2fa settings + client.patchA("/accounts/merchant") { + json { + "tan_channel" to "email" } + }.expectChallenge(TanChannel.sms, "+88") + .expectChallenge(TanChannel.email, "email@example.com") + .assertNoContent() + // Check invalidated + client.postA("/accounts/merchant/challenge/${it.challenge_id}") + .expectTransmission(TanChannel.email, "email@example.com") + assertNotNull(tanCode("email@example.com")) } + // Unknown challenge + client.postA("/accounts/merchant/challenge/42") + .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + } + + // POST /accounts/{USERNAME}/challenge/{challenge_id} + @Test + fun sendTanErr() = bankSetup("test_tan_err.conf") { _ -> // Check fail client.patch("/accounts/merchant") { pwAuth("admin") json { - "contact_data" to obj { - "email" to "test@test.com" - } - "tan_channel" to "email" + "contact_data" to obj { "phone" to "+1234" } + "tan_channel" to "sms" } }.assertNoContent() client.patchA("/accounts/merchant") { @@ -1400,12 +1475,6 @@ class CoreBankTanApiTest { client.postA("/accounts/merchant/challenge/${it.challenge_id}") .assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED) } - - // TODO check what happens when tan info or tan channel change - - // Unknown challenge - client.postA("/accounts/merchant/challenge/42") - .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm @@ -1413,45 +1482,75 @@ class CoreBankTanApiTest { fun confirm() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm") - client.patch("/accounts/merchant") { - pwAuth("admin") - json { - "contact_data" to obj { - "phone" to "+99" - } - "tan_channel" to "sms" - } - }.assertNoContent() - val id = client.patchA("/accounts/merchant") { + fillTanInfo("merchant") + + // Check simple case + client.patchA("/accounts/merchant") { json { "is_public" to false } - }.assertAcceptedJson<TanChallenge>().challenge_id - client.postA("/accounts/merchant/challenge/$id").assertOk() - val code = smsCode("+99") - - // Check bad TAN code - 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") { - json { "tan" to code } - }.assertNoContent() - // Check idempotence - client.postA("/accounts/merchant/challenge/$id/confirm") { - json { "tan" to code } - }.assertNoContent() + }.assertAcceptedJson<TanChallenge> { + val id = it.challenge_id + val info = client.postA("/accounts/merchant/challenge/$id") + .assertOkJson<TanTransmission>().tan_info + val code = tanCode(info) + + // Check bad TAN code + 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) - // TODO check what happens when tan info or tan channel change + // Check OK + client.postA("/accounts/merchant/challenge/$id/confirm") { + json { "tan" to code } + }.assertNoContent() + // Check idempotence + client.postA("/accounts/merchant/challenge/$id/confirm") { + json { "tan" to code } + }.assertNoContent() - // Unknown challenge - client.postA("/accounts/merchant/challenge/42/confirm") { - json { "tan" to code } - }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + // Unknown challenge + client.postA("/accounts/merchant/challenge/42/confirm") { + json { "tan" to code } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + } + + // Check invalidation + client.patchA("/accounts/merchant") { + json { "is_public" to true } + }.assertAcceptedJson<TanChallenge> { + val id = it.challenge_id + val info = client.postA("/accounts/merchant/challenge/$id") + .assertOkJson<TanTransmission>().tan_info + + // Check invalidated + fillTanInfo("merchant") + client.postA("/accounts/merchant/challenge/$id/confirm") { + json { "tan" to tanCode(info) } + }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) + + val new = client.postA("/accounts/merchant/challenge/$id") + .assertOkJson<TanTransmission>().tan_info + val code = tanCode(new) + // Idempotent patch does nothing + client.patchA("/accounts/merchant") { + json { + "contact_data" to obj { "phone" to "+88" } + "tan_channel" to "sms" + } + } + client.postA("/accounts/merchant/challenge/$id/confirm") { + json { "tan" to code } + }.assertNoContent() + + // Solved challenge remain solved + fillTanInfo("merchant") + client.postA("/accounts/merchant/challenge/$id/confirm") { + json { "tan" to code } + }.assertNoContent() + } } }
\ No newline at end of file diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt index 71c5cf23..d0882c80 100644 --- a/bank/src/test/kotlin/helpers.kt +++ b/bank/src/test/kotlin/helpers.kt @@ -26,6 +26,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.util.zip.DeflaterOutputStream import kotlin.test.* +import kotlin.random.Random import kotlinx.coroutines.* import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode @@ -168,6 +169,18 @@ suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String } } +/** Check [account] tan channel and info */ +suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair<TanChannel?, String?> { + val res = client.getA("/accounts/$account").assertOkJson<AccountData>() + val channel: TanChannel? = res.tan_channel + return Pair(channel, when (channel) { + TanChannel.sms -> res.contact_data!!.phone.get() + TanChannel.email -> res.contact_data!!.email.get() + null -> null + else -> null + }) +} + /** 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.postA("/accounts/$from/transactions") { @@ -225,7 +238,7 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) { res }.assertOkJson<CashoutPending> { client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") { - json { "tan" to smsCode("+99") } + json { "tan" to tanCode("+99") } }.assertNoContent() } } @@ -259,7 +272,7 @@ suspend fun ApplicationTestBuilder.fillTanInfo(account: String) { pwAuth("admin") json { "contact_data" to obj { - "phone" to "+42" + "phone" to "+${Random.nextInt(0, 10000)}" } "tan_channel" to "sms" } @@ -280,7 +293,7 @@ suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount { .assertOkJson<ConversionResponse>().amount_credit } -suspend fun smsCode(info: String): String? { +suspend fun tanCode(info: String): String? { val file = File("/tmp/tan-$info.txt"); if (file.exists()) { val code = file.readText() @@ -325,13 +338,25 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse { return this } -suspend fun HttpResponse.assertChallenge(check: suspend () -> Unit = {}): HttpResponse { +suspend fun HttpResponse.challenge(): HttpResponse { + return if (this.status == HttpStatusCode.Accepted) { + this.assertChallenge() + } else { + this + } +} + +suspend fun HttpResponse.assertChallenge( + check: suspend (TanChannel, String) -> Unit = { _, _ -> } +): HttpResponse { val id = this.assertAcceptedJson<TanChallenge>().challenge_id val username = this.call.request.url.pathSegments[2] - this.call.client.postA("/accounts/$username/challenge/$id").assertOk() - check() + val res = this.call.client.postA("/accounts/$username/challenge/$id").assertOkJson<TanTransmission>() + check(res.tan_channel, res.tan_info) + val code = tanCode(res.tan_info) + assertNotNull(code) return this.call.client.postA("/accounts/$username/challenge/$id/confirm") { - json { "tan" to smsCode("+42") } + json { "tan" to code } } } diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql index f6c6d86d..ae3278c8 100644 --- a/database-versioning/libeufin-bank-procedures.sql +++ b/database-versioning/libeufin-bank-procedures.sql @@ -1374,11 +1374,11 @@ retransmit BOOLEAN; BEGIN -- Retreive account id SELECT customer_id, tan_channel, CASE - WHEN tan_channel = 'sms' THEN phone - WHEN tan_channel = 'email' THEN email - END - INTO account_id, out_tan_channel, out_tan_info - FROM customers WHERE login = in_login; + WHEN tan_channel = 'sms' THEN phone + WHEN tan_channel = 'email' THEN email + END +INTO account_id, out_tan_channel, out_tan_info +FROM customers WHERE login = in_login; -- Recover expiration date SELECT @@ -1429,7 +1429,9 @@ CREATE FUNCTION tan_challenge_try ( OUT out_expired BOOLEAN, -- Success return OUT out_op op_enum, - OUT out_body TEXT + OUT out_body TEXT, + OUT out_channel tan_enum, + OUT out_info TEXT ) LANGUAGE plpgsql as $$ DECLARE @@ -1458,7 +1460,8 @@ ELSIF NOT out_ok OR out_no_retry OR out_expired THEN END IF; -- Recover body and op from challenge -SELECT body, op INTO out_body, out_op +SELECT body, op, tan_channel, tan_info + INTO out_body, out_op, out_channel, out_info 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'; |