taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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:
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderManager.kt | 37+++++++++++++++++--------------------
Mmerchant-terminal/src/main/res/values/strings.xml | 6++++++
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>