MfaUtils.kt (17369B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.lib.android 18 19 import android.app.Dialog 20 import android.content.Context 21 import android.content.res.Configuration.UI_MODE_NIGHT_MASK 22 import android.content.res.Configuration.UI_MODE_NIGHT_YES 23 import android.graphics.Color.TRANSPARENT 24 import android.graphics.drawable.ColorDrawable 25 import android.view.ViewGroup.LayoutParams.MATCH_PARENT 26 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT 27 import androidx.lifecycle.setViewTreeLifecycleOwner 28 import androidx.savedstate.setViewTreeSavedStateRegistryOwner 29 import android.widget.Toast 30 import androidx.compose.foundation.BorderStroke 31 import androidx.compose.foundation.clickable 32 import androidx.compose.foundation.interaction.MutableInteractionSource 33 import androidx.compose.foundation.layout.Arrangement 34 import androidx.compose.foundation.layout.Box 35 import androidx.compose.foundation.layout.Column 36 import androidx.compose.foundation.layout.ColumnScope 37 import androidx.compose.foundation.layout.Row 38 import androidx.compose.foundation.layout.fillMaxWidth 39 import androidx.compose.foundation.layout.padding 40 import androidx.compose.foundation.layout.size 41 import androidx.compose.foundation.rememberScrollState 42 import androidx.compose.foundation.shape.RoundedCornerShape 43 import androidx.compose.foundation.text.BasicTextField 44 import androidx.compose.foundation.text.KeyboardActions 45 import androidx.compose.foundation.text.KeyboardOptions 46 import androidx.compose.foundation.verticalScroll 47 import androidx.compose.material3.Button 48 import androidx.compose.material3.ColorScheme 49 import androidx.compose.material3.MaterialTheme 50 import androidx.compose.material3.OutlinedButton 51 import androidx.compose.material3.Surface 52 import androidx.compose.material3.Text 53 import androidx.compose.material3.darkColorScheme 54 import androidx.compose.material3.lightColorScheme 55 import androidx.compose.runtime.Composable 56 import androidx.compose.runtime.LaunchedEffect 57 import androidx.compose.runtime.getValue 58 import androidx.compose.runtime.mutableStateOf 59 import androidx.compose.runtime.remember 60 import androidx.compose.runtime.setValue 61 import androidx.compose.ui.Alignment 62 import androidx.compose.ui.Modifier 63 import androidx.compose.ui.focus.FocusRequester 64 import androidx.compose.ui.focus.focusRequester 65 import androidx.compose.ui.graphics.Color 66 import androidx.compose.ui.graphics.SolidColor 67 import androidx.compose.ui.platform.ComposeView 68 import androidx.compose.ui.platform.ViewCompositionStrategy 69 import androidx.compose.ui.res.stringResource 70 import androidx.compose.ui.semantics.contentDescription 71 import androidx.compose.ui.semantics.semantics 72 import androidx.compose.ui.text.TextStyle 73 import androidx.compose.ui.text.font.FontWeight 74 import androidx.compose.ui.text.input.ImeAction 75 import androidx.compose.ui.text.input.KeyboardType 76 import androidx.compose.ui.unit.dp 77 import androidx.compose.ui.unit.sp 78 import androidx.fragment.app.Fragment 79 import com.google.android.material.color.MaterialColors 80 import io.ktor.client.plugins.ClientRequestException 81 import kotlinx.coroutines.Dispatchers 82 import kotlinx.coroutines.suspendCancellableCoroutine 83 import kotlinx.coroutines.withContext 84 import net.taler.common.Challenge 85 import net.taler.common.R 86 import kotlin.coroutines.resume 87 88 enum class ChallengeRetryDecision { 89 Retry, 90 Resend, 91 Abort 92 } 93 94 class ChallengeCancelledException : Exception() 95 96 suspend fun Fragment.handleChallengeResponse( 97 challenges: List<Challenge>, 98 combiAnd: Boolean, 99 onRequestChallenge: suspend (challengeId: String) -> Unit, 100 onConfirmChallenge: suspend (challengeId: String, tan: String) -> Unit, 101 ): List<String> { 102 if (challenges.isEmpty()) return emptyList() 103 val challengesToSolve = if (combiAnd) { 104 challenges 105 } else { 106 val selected = selectChallenge(challenges) ?: return emptyList() 107 listOf(selected) 108 } 109 110 val solvedIds = mutableListOf<String>() 111 for (challenge in challengesToSolve) { 112 onRequestChallenge(challenge.challengeId) 113 114 while (true) { 115 val tan = promptForTan(challenge) ?: return emptyList() 116 try { 117 onConfirmChallenge(challenge.challengeId, tan) 118 solvedIds.add(challenge.challengeId) 119 break 120 } catch (e: Exception) { 121 when (handleChallengeConfirmError(e)) { 122 ChallengeRetryDecision.Retry -> continue 123 ChallengeRetryDecision.Resend -> { 124 onRequestChallenge(challenge.challengeId) 125 continue 126 } 127 ChallengeRetryDecision.Abort -> return emptyList() 128 } 129 } 130 } 131 } 132 return solvedIds 133 } 134 135 suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? = 136 showMfaDialog(cancelResult = null) { finish -> 137 MfaDialogSurface { 138 Text( 139 text = stringResource(R.string.mfa_choose_title), 140 style = MaterialTheme.typography.headlineSmall, 141 ) 142 Column( 143 modifier = Modifier 144 .fillMaxWidth() 145 .verticalScroll(rememberScrollState()), 146 verticalArrangement = Arrangement.spacedBy(8.dp), 147 ) { 148 challenges.forEach { challenge -> 149 Button( 150 onClick = { finish(challenge) }, 151 modifier = Modifier.fillMaxWidth(), 152 ) { 153 Text("${challenge.tanChannel}: ${challenge.tanInfo}") 154 } 155 } 156 } 157 DialogActions( 158 onCancel = { finish(null) }, 159 ) 160 } 161 } 162 163 suspend fun Fragment.promptForTan( 164 challenge: Challenge, 165 ): String? = 166 showMfaDialog(cancelResult = null) { finish -> 167 var code by remember { mutableStateOf("") } 168 var showIncompleteError by remember { mutableStateOf(false) } 169 val submit = { 170 val normalizedCode = normalizeMfaCode(code) 171 if (normalizedCode == null) { 172 showIncompleteError = true 173 } else { 174 finish(formatMfaCode(normalizedCode)) 175 } 176 } 177 178 MfaDialogSurface { 179 Text( 180 text = stringResource(R.string.mfa_challenge_title), 181 style = MaterialTheme.typography.headlineSmall, 182 ) 183 Text( 184 text = stringResource( 185 R.string.mfa_challenge_message, 186 challenge.tanChannel.name, 187 challenge.tanInfo, 188 ), 189 style = MaterialTheme.typography.bodyMedium, 190 color = MaterialTheme.colorScheme.onSurfaceVariant, 191 ) 192 MfaCodeInput( 193 code = code, 194 isError = showIncompleteError, 195 onCodeChanged = { 196 code = it 197 showIncompleteError = false 198 }, 199 onSubmit = submit, 200 ) 201 if (showIncompleteError) { 202 Text( 203 text = stringResource(R.string.mfa_challenge_code_incomplete), 204 style = MaterialTheme.typography.bodySmall, 205 color = MaterialTheme.colorScheme.error, 206 ) 207 } 208 DialogActions( 209 onCancel = { finish(null) }, 210 onConfirm = submit, 211 ) 212 } 213 } 214 215 @Composable 216 private fun MfaDialogSurface( 217 content: @Composable ColumnScope.() -> Unit, 218 ) { 219 Surface( 220 modifier = Modifier 221 .fillMaxWidth() 222 .padding(24.dp), 223 shape = RoundedCornerShape(28.dp), 224 color = MaterialTheme.colorScheme.surface, 225 tonalElevation = 6.dp, 226 shadowElevation = 6.dp, 227 ) { 228 Column( 229 modifier = Modifier.padding(24.dp), 230 verticalArrangement = Arrangement.spacedBy(20.dp), 231 content = content, 232 ) 233 } 234 } 235 236 @Composable 237 private fun MfaCodeInput( 238 code: String, 239 isError: Boolean, 240 onCodeChanged: (String) -> Unit, 241 onSubmit: () -> Unit, 242 ) { 243 val focusRequester = remember { FocusRequester() } 244 val interactionSource = remember { MutableInteractionSource() } 245 val codeHint = stringResource(R.string.mfa_challenge_code_hint) 246 247 LaunchedEffect(Unit) { 248 focusRequester.requestFocus() 249 } 250 251 BasicTextField( 252 value = code, 253 onValueChange = { value -> 254 onCodeChanged(value.filter(Char::isDigit).take(MFA_CODE_DIGITS)) 255 }, 256 modifier = Modifier 257 .fillMaxWidth() 258 .focusRequester(focusRequester) 259 .semantics { contentDescription = codeHint }, 260 textStyle = TextStyle(color = Color.Transparent), 261 cursorBrush = SolidColor(Color.Transparent), 262 singleLine = true, 263 keyboardOptions = KeyboardOptions( 264 keyboardType = KeyboardType.NumberPassword, 265 imeAction = ImeAction.Done, 266 ), 267 keyboardActions = KeyboardActions(onDone = { onSubmit() }), 268 interactionSource = interactionSource, 269 decorationBox = { 270 Row( 271 modifier = Modifier 272 .fillMaxWidth() 273 .clickable( 274 interactionSource = interactionSource, 275 indication = null, 276 ) { focusRequester.requestFocus() }, 277 horizontalArrangement = Arrangement.Center, 278 verticalAlignment = Alignment.CenterVertically, 279 ) { 280 repeat(MFA_CODE_DIGITS) { index -> 281 if (index == MFA_CODE_DIGITS / 2) { 282 Text( 283 text = "-", 284 modifier = Modifier.padding(horizontal = 3.dp), 285 color = MaterialTheme.colorScheme.primary, 286 fontSize = 22.sp, 287 fontWeight = FontWeight.Bold, 288 ) 289 } 290 MfaCodeDigit( 291 digit = code.getOrNull(index), 292 isActive = index == code.length.coerceAtMost(MFA_CODE_DIGITS - 1), 293 isError = isError, 294 ) 295 } 296 } 297 }, 298 ) 299 } 300 301 @Composable 302 private fun MfaCodeDigit( 303 digit: Char?, 304 isActive: Boolean, 305 isError: Boolean, 306 ) { 307 val borderColor = when { 308 isError -> MaterialTheme.colorScheme.error 309 isActive -> MaterialTheme.colorScheme.primary 310 else -> MaterialTheme.colorScheme.outline 311 } 312 Box( 313 modifier = Modifier 314 .padding(horizontal = 1.dp) 315 .size(width = 30.dp, height = 52.dp), 316 contentAlignment = Alignment.Center, 317 ) { 318 Surface( 319 modifier = Modifier.matchParentSize(), 320 shape = RoundedCornerShape(6.dp), 321 color = MaterialTheme.colorScheme.surface, 322 border = BorderStroke(if (isActive) 2.dp else 1.dp, borderColor), 323 ) {} 324 Text( 325 text = digit?.toString().orEmpty(), 326 color = MaterialTheme.colorScheme.onSurface, 327 fontSize = 20.sp, 328 fontWeight = FontWeight.Bold, 329 ) 330 } 331 } 332 333 @Composable 334 private fun DialogActions( 335 onCancel: () -> Unit, 336 onConfirm: (() -> Unit)? = null, 337 ) { 338 Row( 339 modifier = Modifier.fillMaxWidth(), 340 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), 341 ) { 342 OutlinedButton(onClick = onCancel) { 343 Text(stringResource(android.R.string.cancel)) 344 } 345 if (onConfirm != null) { 346 Button(onClick = onConfirm) { 347 Text(stringResource(android.R.string.ok)) 348 } 349 } 350 } 351 } 352 353 private suspend fun <T> Fragment.showMfaDialog( 354 cancelResult: T, 355 content: @Composable ((T) -> Unit) -> Unit, 356 ): T = withContext(Dispatchers.Main) { 357 suspendCancellableCoroutine { continuation -> 358 val dialog = Dialog(requireContext()) 359 var completed = false 360 361 fun finish(result: T) { 362 if (completed) return 363 completed = true 364 if (continuation.isActive) continuation.resume(result) 365 dialog.dismiss() 366 } 367 368 val composeView = ComposeView(requireContext()).apply { 369 setViewTreeLifecycleOwner(viewLifecycleOwner) 370 setViewTreeSavedStateRegistryOwner(this@showMfaDialog) 371 setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) 372 setContent { 373 MaterialTheme(colorScheme = mfaColorScheme(context)) { 374 content(::finish) 375 } 376 } 377 } 378 dialog.setContentView(composeView) 379 dialog.setCanceledOnTouchOutside(true) 380 dialog.setOnCancelListener { finish(cancelResult) } 381 dialog.show() 382 dialog.window?.setBackgroundDrawable(ColorDrawable(TRANSPARENT)) 383 dialog.window?.setLayout(MATCH_PARENT, WRAP_CONTENT) 384 385 continuation.invokeOnCancellation { 386 composeView.post { dialog.dismiss() } 387 } 388 } 389 } 390 391 suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDecision = 392 withContext(Dispatchers.Main) { 393 if (e is ClientRequestException) { 394 when (e.response.status.value) { 395 409 -> { 396 Toast.makeText( 397 requireContext(), 398 R.string.mfa_challenge_invalid, 399 Toast.LENGTH_LONG, 400 ).show() 401 return@withContext ChallengeRetryDecision.Retry 402 } 403 429 -> { 404 Toast.makeText( 405 requireContext(), 406 R.string.mfa_challenge_retry, 407 Toast.LENGTH_LONG, 408 ).show() 409 return@withContext ChallengeRetryDecision.Resend 410 } 411 } 412 } 413 Toast.makeText( 414 requireContext(), 415 R.string.mfa_challenge_failed, 416 Toast.LENGTH_LONG, 417 ).show() 418 ChallengeRetryDecision.Abort 419 } 420 421 internal fun normalizeMfaCode(value: String): String? { 422 val digits = value.filter(Char::isDigit) 423 return digits.takeIf { it.length == MFA_CODE_DIGITS } 424 } 425 426 internal fun formatMfaCode(value: String): String = 427 value.take(MFA_CODE_DIGITS / 2) + "-" + value.drop(MFA_CODE_DIGITS / 2) 428 429 private fun mfaColorScheme(context: Context): ColorScheme { 430 val isDark = context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK == 431 UI_MODE_NIGHT_YES 432 val primary = context.materialColor(androidx.appcompat.R.attr.colorPrimary) 433 val onPrimary = context.materialColor(com.google.android.material.R.attr.colorOnPrimary) 434 val primaryContainer = context.materialColor( 435 com.google.android.material.R.attr.colorPrimaryContainer, 436 ) 437 val onPrimaryContainer = context.materialColor( 438 com.google.android.material.R.attr.colorOnPrimaryContainer, 439 ) 440 val surface = context.materialColor(com.google.android.material.R.attr.colorSurface) 441 val onSurface = context.materialColor(com.google.android.material.R.attr.colorOnSurface) 442 val surfaceVariant = context.materialColor( 443 com.google.android.material.R.attr.colorSurfaceVariant, 444 ) 445 val onSurfaceVariant = context.materialColor( 446 com.google.android.material.R.attr.colorOnSurfaceVariant, 447 ) 448 val outline = context.materialColor(com.google.android.material.R.attr.colorOutline) 449 450 return if (isDark) { 451 darkColorScheme( 452 primary = primary, 453 onPrimary = onPrimary, 454 primaryContainer = primaryContainer, 455 onPrimaryContainer = onPrimaryContainer, 456 surface = surface, 457 onSurface = onSurface, 458 surfaceVariant = surfaceVariant, 459 onSurfaceVariant = onSurfaceVariant, 460 outline = outline, 461 ) 462 } else { 463 lightColorScheme( 464 primary = primary, 465 onPrimary = onPrimary, 466 primaryContainer = primaryContainer, 467 onPrimaryContainer = onPrimaryContainer, 468 surface = surface, 469 onSurface = onSurface, 470 surfaceVariant = surfaceVariant, 471 onSurfaceVariant = onSurfaceVariant, 472 outline = outline, 473 ) 474 } 475 } 476 477 private fun Context.materialColor(attr: Int): Color = 478 Color(MaterialColors.getColor(this, attr, android.graphics.Color.MAGENTA)) 479 480 private const val MFA_CODE_DIGITS = 8