commit 38cf193e9e195b3ce3ca7fb0631a014c5f6c1c37
parent bf79b73f40d34371739fca5c8dc818b4cb1f25ef
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Sat, 17 Jan 2026 14:32:23 +0100
[merchant-terminal] very simple support of MFA for merchant password login
Diffstat:
4 files changed, 274 insertions(+), 32 deletions(-)
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt
@@ -24,6 +24,7 @@ import android.content.pm.PackageManager
import android.media.Image
import android.os.Bundle
import android.util.Log
+import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
@@ -31,9 +32,11 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.Button
+import android.widget.EditText
import android.widget.RadioButton
import android.widget.TextView
import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.camera.core.CameraSelector
@@ -51,6 +54,7 @@ import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import net.taler.common.navigate
import net.taler.merchantpos.MainViewModel
@@ -64,6 +68,7 @@ import android.text.format.DateFormat
import com.google.zxing.common.HybridBinarizer
import java.util.Calendar
import java.util.Locale
+import kotlin.coroutines.resume
/**
* Fragment that displays merchant settings, either by scanning a QR code
@@ -153,11 +158,13 @@ class ConfigFragment : Fragment() {
TokenDuration.Micros(microsToDeadline)
}
- // fetch limited write token
+ // fetch limited write token (with optional 2FA)
val limitedToken = try {
- withContext(Dispatchers.IO) {
- configManager.fetchLimitedAccessToken(url, username, initialSecret, duration)
- }
+ fetchLimitedAccessTokenWithMfa(url, username, initialSecret, duration)
+ } catch (e: ChallengeCancelledException) {
+ ui.progressBarNew.visibility = INVISIBLE
+ ui.okNewButton.visibility = VISIBLE
+ return@launch
} catch (e: Exception) {
ui.progressBarNew.visibility = INVISIBLE
ui.okNewButton.visibility = VISIBLE
@@ -319,6 +326,167 @@ class ConfigFragment : Fragment() {
ui.merchantUrlView.editText!!.setText(baseUrl)
}
+ private suspend fun fetchLimitedAccessTokenWithMfa(
+ baseUrl: String,
+ username: String,
+ initialSecret: String,
+ duration: TokenDuration
+ ): String {
+ var challengeIds: List<String> = emptyList()
+ while (true) {
+ try {
+ return withContext(Dispatchers.IO) {
+ configManager.fetchLimitedAccessToken(
+ baseUrl,
+ username,
+ initialSecret,
+ duration,
+ challengeIds
+ )
+ }
+ } catch (e: ChallengeRequiredException) {
+ val solvedIds = handleChallengeResponse(baseUrl, username, e.challengeResponse)
+ if (solvedIds.isEmpty()) {
+ throw ChallengeCancelledException()
+ }
+ challengeIds = solvedIds
+ }
+ }
+ }
+
+ private suspend fun handleChallengeResponse(
+ baseUrl: String,
+ username: String,
+ response: ChallengeResponse
+ ): List<String> {
+ if (response.challenges.isEmpty()) return emptyList()
+ val challengesToSolve = if (response.combi_and) {
+ response.challenges
+ } else {
+ val selected = selectChallenge(response.challenges) ?: return emptyList()
+ listOf(selected)
+ }
+
+ val solvedIds = mutableListOf<String>()
+ for (challenge in challengesToSolve) {
+ withContext(Dispatchers.IO) {
+ configManager.requestChallenge(baseUrl, username, challenge.challenge_id)
+ }
+
+ while (true) {
+ val tan = promptForTan(challenge) ?: return emptyList()
+ try {
+ withContext(Dispatchers.IO) {
+ configManager.confirmChallenge(
+ baseUrl,
+ username,
+ challenge.challenge_id,
+ tan
+ )
+ }
+ solvedIds.add(challenge.challenge_id)
+ break
+ } catch (e: Exception) {
+ when (handleChallengeConfirmError(e)) {
+ ChallengeRetryDecision.Retry -> continue
+ ChallengeRetryDecision.Resend -> {
+ withContext(Dispatchers.IO) {
+ configManager.requestChallenge(
+ baseUrl,
+ username,
+ challenge.challenge_id
+ )
+ }
+ continue
+ }
+ ChallengeRetryDecision.Abort -> return emptyList()
+ }
+ }
+ }
+ }
+ return solvedIds
+ }
+
+ private suspend fun selectChallenge(challenges: List<Challenge>): Challenge? =
+ withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { cont ->
+ val labels = challenges.map { c ->
+ "${c.tan_channel}: ${c.tan_info}"
+ }.toTypedArray()
+ AlertDialog.Builder(requireContext())
+ .setTitle(R.string.mfa_choose_title)
+ .setItems(labels) { _, which ->
+ cont.resume(challenges[which])
+ }
+ .setOnCancelListener { cont.resume(null) }
+ .show()
+ }
+ }
+
+ private suspend fun promptForTan(challenge: Challenge): String? =
+ withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { cont ->
+ val input = EditText(requireContext()).apply {
+ inputType = InputType.TYPE_CLASS_NUMBER
+ }
+ val message = getString(
+ R.string.mfa_challenge_message,
+ challenge.tan_channel,
+ challenge.tan_info
+ )
+ AlertDialog.Builder(requireContext())
+ .setTitle(R.string.mfa_challenge_title)
+ .setMessage(message)
+ .setView(input)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ cont.resume(input.text.toString().trim())
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ cont.resume(null)
+ }
+ .setOnCancelListener { cont.resume(null) }
+ .show()
+ }
+ }
+
+ private suspend fun handleChallengeConfirmError(e: Exception): ChallengeRetryDecision =
+ withContext(Dispatchers.Main) {
+ if (e is io.ktor.client.plugins.ClientRequestException) {
+ when (e.response.status.value) {
+ 409 -> {
+ Toast.makeText(
+ requireContext(),
+ R.string.mfa_challenge_invalid,
+ Toast.LENGTH_LONG
+ ).show()
+ return@withContext ChallengeRetryDecision.Retry
+ }
+ 429 -> {
+ Toast.makeText(
+ requireContext(),
+ R.string.mfa_challenge_retry,
+ Toast.LENGTH_LONG
+ ).show()
+ return@withContext ChallengeRetryDecision.Resend
+ }
+ }
+ }
+ Toast.makeText(
+ requireContext(),
+ R.string.mfa_challenge_failed,
+ Toast.LENGTH_LONG
+ ).show()
+ ChallengeRetryDecision.Abort
+ }
+
+ private enum class ChallengeRetryDecision {
+ Retry,
+ Resend,
+ Abort
+ }
+
+ private class ChallengeCancelledException : Exception()
+
// ─── CameraX integration ───────────────────────────────────────────
@@ -444,4 +612,3 @@ class ConfigFragment : Fragment() {
} catch (_: Exception) { /* no-op */ }
}
}
-
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt
@@ -48,6 +48,7 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
@@ -105,6 +106,26 @@ private data class LimitedTokenResponse(
val expiration: TokenExpiration
)
+@kotlinx.serialization.Serializable
+data class ChallengeResponse(
+ val challenges: List<Challenge>,
+ val combi_and: Boolean
+)
+
+@kotlinx.serialization.Serializable
+data class Challenge(
+ val challenge_id: String,
+ val tan_channel: String,
+ val tan_info: String
+)
+
+@kotlinx.serialization.Serializable
+data class ChallengeConfirmRequest(
+ val tan: String
+)
+
+class ChallengeRequiredException(val challengeResponse: ChallengeResponse) : Exception()
+
@kotlinx.serialization.Serializable(with = TokenExpiration.Serializer::class)
sealed class TokenExpiration {
data class Seconds(val t_s: Long) : TokenExpiration()
@@ -370,7 +391,8 @@ class ConfigManager(
baseUrl: String,
username: String,
initialSecret: String,
- duration: TokenDuration
+ duration: TokenDuration,
+ challengeIds: List<String> = emptyList()
): String {
val tokenUrl = baseUrl.toUri()
.buildUpon()
@@ -382,17 +404,67 @@ class ConfigManager(
.toString()
val bearer = "Bearer secret-token:$initialSecret"
- val resp: LimitedTokenResponse = httpClient
- .post(tokenUrl) {
- header(HttpHeaders.Authorization, bearer)
- contentType(ContentType.Application.Json)
- setBody(TokenRequest(scope = "write", duration = duration))
+ val response = httpClient.post(tokenUrl) {
+ header(HttpHeaders.Authorization, bearer)
+ if (challengeIds.isNotEmpty()) {
+ header("Taler-Challenge-Ids", challengeIds.joinToString(","))
}
- .body()
+ contentType(ContentType.Application.Json)
+ setBody(TokenRequest(scope = "write", duration = duration))
+ }
+
+ if (response.status == HttpStatusCode.Accepted) {
+ val challenge: ChallengeResponse = response.body()
+ throw ChallengeRequiredException(challenge)
+ }
+
+ val resp: LimitedTokenResponse = response.body()
return resp.token.removePrefix("secret-token:")
}
+ suspend fun requestChallenge(
+ baseUrl: String,
+ username: String,
+ challengeId: String
+ ) {
+ val challengeUrl = baseUrl.toUri()
+ .buildUpon()
+ .appendPath("instances")
+ .appendPath(username)
+ .appendPath("challenge")
+ .appendPath(challengeId)
+ .build()
+ .toString()
+
+ httpClient.post(challengeUrl) {
+ contentType(ContentType.Application.Json)
+ setBody(buildJsonObject { })
+ }
+ }
+
+ suspend fun confirmChallenge(
+ baseUrl: String,
+ username: String,
+ challengeId: String,
+ tan: String
+ ) {
+ val confirmUrl = baseUrl.toUri()
+ .buildUpon()
+ .appendPath("instances")
+ .appendPath(username)
+ .appendPath("challenge")
+ .appendPath(challengeId)
+ .appendPath("confirm")
+ .build()
+ .toString()
+
+ httpClient.post(confirmUrl) {
+ contentType(ContentType.Application.Json)
+ setBody(ChallengeConfirmRequest(tan = tan))
+ }
+ }
+
@UiThread
fun forgetPassword() {
config = when (val c = config) {
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt
@@ -62,6 +62,9 @@ class OrderManager(private val context: Context) : ConfigurationReceiver {
// group products by categories
productsByCategory.clear()
val unknownCategory = Category(-1, context.getString(R.string.product_category_uncategorized))
+ posConfig.categories.forEach { category ->
+ productsByCategory[category] = ArrayList()
+ }
posConfig.products.forEach { product ->
val productCurrency = product.price.currency
if (productCurrency != currency) {
@@ -76,28 +79,22 @@ class OrderManager(private val context: Context) : ConfigurationReceiver {
unknownCategory
}
- if (productsByCategory.containsKey(category)) {
- productsByCategory[category]?.add(product)
- } else {
- productsByCategory[category] = ArrayList<ConfigProduct>().apply { add(product) }
- }
+ productsByCategory.getOrPut(category) { ArrayList() }.add(product)
}
}
- return if (productsByCategory.size > 0) {
- this.currency = currency
- mCategories.postValue(posConfig.categories +
- if(productsByCategory.containsKey(unknownCategory)) {
- listOf(unknownCategory)
- } else {
- emptyList()
- })
- mProducts.postValue(productsByCategory[posConfig.categories[0]])
- orders.clear()
- orderCounter = 0
- orders[0] = MutableLiveOrder(0, currency, productsByCategory)
- mCurrentOrderId.postValue(0)
- null // success, no error string
- } else context.getString(R.string.config_error_product_zero)
+ this.currency = currency
+ mCategories.postValue(posConfig.categories +
+ if(productsByCategory.containsKey(unknownCategory)) {
+ listOf(unknownCategory)
+ } else {
+ emptyList()
+ })
+ mProducts.postValue(productsByCategory[posConfig.categories[0]] ?: emptyList())
+ orders.clear()
+ orderCounter = 0
+ orders[0] = MutableLiveOrder(0, currency, productsByCategory)
+ mCurrentOrderId.postValue(0)
+ return null // success, no error string
}
@UiThread
diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml
@@ -104,6 +104,12 @@
<string name="custom_duration">Custom duration</string>
<string name="duration">Duration</string>
<string name="token_validity_deadline">Token validity deadline:</string>
+ <string name="mfa_challenge_title">Two-factor authentication</string>
+ <string name="mfa_challenge_message">A confirmation code was sent via %1$s (%2$s). Enter the code to continue.</string>
+ <string name="mfa_choose_title">Choose verification method</string>
+ <string name="mfa_challenge_invalid">Incorrect code. Please try again.</string>
+ <string name="mfa_challenge_retry">Too many attempts. A new code has been sent.</string>
+ <string name="mfa_challenge_failed">Verification failed. Please try again later.</string>
<string name="expires_on">Expires on…</string>
<string name="never_expires">Never expires</string>
<string name="no_deadline_set">No deadline set</string>