Implemented ProtectAppSettingsDialog

This commit is contained in:
dankito 2024-09-18 04:25:59 +02:00
parent f1c4c8ca13
commit 6e6449e956
17 changed files with 350 additions and 15 deletions

View File

@ -1,7 +1,6 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins {
@ -94,6 +93,8 @@ kotlin {
implementation(libs.androidx.activity.compose)
implementation(libs.sqldelight.android.driver)
implementation(libs.favre.bcrypt)
}
nativeMain.dependencies {
@ -110,6 +111,8 @@ kotlin {
implementation(libs.sqldelight.sqlite.driver)
implementation(libs.favre.bcrypt)
implementation(libs.logback)
}
}

View File

@ -0,0 +1,24 @@
package net.codinux.banking.ui.forms
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
fun SegmentedControlPreview() {
SegmentedControl(
options = listOf("Option 1", "Option 2", "Option 3"),
selectedOption = "Option 1",
onOptionSelected = { }
)
}
@Preview
@Composable
fun SegmentedControlPreview_OnlyTwoOptions() {
SegmentedControl(
options = listOf("Option 1", "Option 2"),
selectedOption = "Option 2",
onOptionSelected = { }
)
}

View File

@ -0,0 +1,15 @@
package net.codinux.banking.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
@Preview
@Composable
fun ProtectAppSettingsDialogPreview() {
val appSettings = AppSettings(AppAuthenticationMethod.Password)
ProtectAppSettingsDialog(appSettings) { }
}

View File

@ -0,0 +1,13 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
actual object PasswordService {
actual fun hashPassword(password: String): String =
BCrypt.withDefaults().hashToString(12, password.toCharArray())
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
}

View File

@ -7,6 +7,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.SaveAs
import androidx.compose.material.icons.outlined.Key
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
@ -124,6 +125,15 @@ fun SideMenuContent() {
drawerState.close()
}
}
NavigationMenuItem(itemModifier, "Appzugang schützen", textColor, horizontalPadding = ItemHorizontalPadding,
icon = { Icon(Icons.Outlined.Key, "Appzugang durch Passwort oder Biometrieeingabe schützen", Modifier.size(iconSize), tint = textColor) }) {
uiState.showProtectAppSettingsScreen.value = true
coroutineScope.launch {
drawerState.close()
}
}
}
}
}

View File

@ -15,10 +15,13 @@ private val formatUtil = DI.formatUtil
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState()
val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState()
val showBankSettingsScreenForBank by uiState.showBankSettingsScreenForBank.collectAsState()
val showBankAccountSettingsScreenForAccount by uiState.showBankAccountSettingsScreenForAccount.collectAsState()
val showExportScreen by uiState.showExportScreen.collectAsState()
val showProtectAppSettingsScreen by uiState.showProtectAppSettingsScreen.collectAsState()
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
@ -35,6 +38,7 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null }
}
showAccountTransactionDetailsScreenForId?.let { transactionId ->
DI.bankingService.getTransaction(transactionId)?.let { transaction ->
AccountTransactionDetailsScreen(transaction) { uiState.showAccountTransactionDetailsScreenForId.value = null }
@ -49,10 +53,15 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
BankAccountSettingsScreen(account) { uiState.showBankAccountSettingsScreenForAccount.value = null }
}
if (showExportScreen) {
ExportScreen { uiState.showExportScreen.value = false }
}
if (showProtectAppSettingsScreen) {
ProtectAppSettingsDialog(uiState.appSettings.value) { uiState.showProtectAppSettingsScreen.value = false }
}
tanChallengeReceived?.let { tanChallengeReceived ->
EnterTanDialog(tanChallengeReceived) {

View File

@ -3,6 +3,7 @@ package net.codinux.banking.ui.config
import net.codinux.banking.client.model.BankAccountType
import net.codinux.banking.client.model.tan.ActionRequiringTan
import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
object Internationalization {
@ -44,4 +45,10 @@ object Internationalization {
BankAccountType.Other -> "Sonstige"
}
fun translate(authenticationMethod: AppAuthenticationMethod): String = when (authenticationMethod) {
AppAuthenticationMethod.None -> "Ungeschützt"
AppAuthenticationMethod.Password -> "Passwort"
AppAuthenticationMethod.Biometric -> "Biometrie"
}
}

View File

@ -17,7 +17,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable // try BasicSecureTextField
fun PasswordTextField(password: String = "", label: String = "Passwort", forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) {
fun PasswordTextField(password: String = "", label: String = "Passwort", modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions? = null, forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) {
var passwordVisible by remember { mutableStateOf(false) }
@ -29,7 +29,7 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
value = password,
onValueChange = { onChange(it) },
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val visibilityIcon = if (passwordVisible) {
@ -43,7 +43,7 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible }
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardOptions = keyboardOptions?.copy(keyboardType = KeyboardType.Password) ?: KeyboardOptions(keyboardType = KeyboardType.Password),
onEnterPressed = onEnterPressed
)
}

View File

@ -0,0 +1,68 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.config.Colors
@Composable
fun <T> SegmentedControl(
options: Collection<T>,
selectedOption: T,
modifier: Modifier = Modifier,
color: Color = Colors.Accent,
cornerSize: Dp = 8.dp,
getOptionDisplayText: ((T) -> String)? = null,
onOptionSelected: (T) -> Unit
) {
Row(horizontalArrangement = Arrangement.Center) {
Row(modifier.height(48.dp).border(2.dp, color, RoundedCornerShape(cornerSize))) {
options.forEachIndexed { index, option ->
val isSelected = option == selectedOption
val backgroundColor = if (isSelected) color else Color.Transparent
val textColor = if (isSelected) Color.White else color
Box(
modifier = Modifier
.clickable { onOptionSelected(option) }
.fillMaxHeight()
.weight(1f)
.let {
if (index == 0) {
it.background(backgroundColor, RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize))
} else if (index == options.size - 1) {
it.background(backgroundColor, RoundedCornerShape(topEnd = cornerSize, bottomEnd = cornerSize))
} else {
it.background(backgroundColor)
}
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = getOptionDisplayText?.invoke(option) ?: option.toString(),
color = textColor,
textAlign = TextAlign.Center
)
}
if (index < options.size - 1) {
Divider(Modifier.fillMaxHeight().width(1.dp), color = color)
}
}
}
}
}

View File

@ -23,6 +23,7 @@ fun FullscreenViewBase(
title: String,
confirmButtonTitle: String = "OK",
confirmButtonEnabled: Boolean = true,
showButtonBar: Boolean = true,
onClosed: () -> Unit,
content: @Composable () -> Unit
) {
@ -46,19 +47,21 @@ fun FullscreenViewBase(
content()
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
// }
if (showButtonBar) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
// }
//
// Spacer(Modifier.width(8.dp))
// Spacer(Modifier.width(8.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled,
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
TextButton(
modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled,
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
}
}
}
}

View File

@ -0,0 +1,125 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.forms.SegmentedControl
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.service.PasswordService
private val buttonHeight = 50.dp
@Composable
fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
val currentAuthenticationMethod = appSettings.authenticationMethod
val isBiometricAuthenticationSupported = false
val supportedAuthenticationMethods = buildList {
add(AppAuthenticationMethod.Password)
if (isBiometricAuthenticationSupported) {
add(AppAuthenticationMethod.Biometric)
}
add(AppAuthenticationMethod.None)
}
var selectedAuthenticationMethod by remember { mutableStateOf(if (appSettings.authenticationMethod == AppAuthenticationMethod.None) AppAuthenticationMethod.Password else appSettings.authenticationMethod) }
var newPassword by remember { mutableStateOf("") }
var confirmedNewPassword by remember { mutableStateOf("") }
val isRequiredDataEntered by remember(newPassword, confirmedNewPassword) {
derivedStateOf { newPassword.isNotBlank() && newPassword == confirmedNewPassword }
}
val coroutineScope = rememberCoroutineScope()
fun saveAppSettings(appSettings: AppSettings) {
coroutineScope.launch {
DI.bankingService.saveAppSettings(appSettings)
onClosed()
}
}
fun setAppPasswordProtection() {
appSettings.authenticationMethod = AppAuthenticationMethod.Password
appSettings.hashedPassword = PasswordService.hashPassword(newPassword)
saveAppSettings(appSettings)
}
fun removeAppProtection() {
appSettings.authenticationMethod = AppAuthenticationMethod.None
appSettings.hashedPassword = null
saveAppSettings(appSettings)
}
FullscreenViewBase("Appzugang schützen", showButtonBar = false, onClosed = onClosed) {
Column(Modifier.fillMaxSize().padding(8.dp)) {
SegmentedControl(supportedAuthenticationMethods, selectedAuthenticationMethod, Modifier.padding(bottom = 20.dp), getOptionDisplayText = { Internationalization.translate(it) }) {
selectedAuthenticationMethod = it
}
Column(Modifier.weight(1f).verticalScroll()) {
Spacer(Modifier.weight(1f))
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
Row(Modifier.fillMaxWidth()) {
if (currentAuthenticationMethod == AppAuthenticationMethod.None) {
Text("Appzugangsschutz ist bereits ungeschützt", fontSize = 18.sp, textAlign = TextAlign.Center)
} else {
Text("Möchten Sie den Appzugangsschutz wirklich entfernen?", fontSize = 18.sp, textAlign = TextAlign.Center)
}
}
}
if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) {
PasswordTextField(newPassword, "Neues Password", keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)) { newPassword = it }
PasswordTextField(confirmedNewPassword, "Password bestätigen", Modifier.padding(top = 16.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)) { confirmedNewPassword = it }
}
Spacer(Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = currentAuthenticationMethod != AppAuthenticationMethod.None,
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { removeAppProtection() }) {
Text("Appzugangsschutz entfernen", color = Color.White)
}
} else {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = isRequiredDataEntered,
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { setAppPasswordProtection() }) {
Text("Setzen", color = Color.White)
}
}
}
}
}
}

View File

@ -0,0 +1,9 @@
package net.codinux.banking.ui.service
expect object PasswordService {
fun hashPassword(password: String): String
fun checkPassword(password: String, hashedPassword: String): Boolean
}

View File

@ -63,6 +63,8 @@ class UiState : ViewModel() {
val showExportScreen = MutableStateFlow(false)
val showProtectAppSettingsScreen = MutableStateFlow(false)
val tanChallengeReceived = MutableStateFlow<TanChallengeReceived?>(null)

View File

@ -0,0 +1,13 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
actual object PasswordService {
actual fun hashPassword(password: String): String =
BCrypt.withDefaults().hashToString(12, password.toCharArray())
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
}

View File

@ -0,0 +1,17 @@
package net.codinux.banking.ui.service
actual object PasswordService {
// for iOS see e.g.
// https://medium.com/@mohamed.ma872/strengthening-mobile-app-security-pbkdf2-bcrypt-and-scrypt-for-android-and-ios-8089b0edbf76
// https://github.com/felipeflorencio/BCryptSwift
actual fun hashPassword(password: String): String {
return password // TODO
}
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
return password == hashedPassword // TODO
}
}

View File

@ -0,0 +1,13 @@
package net.codinux.banking.ui.service
actual object PasswordService {
actual fun hashPassword(password: String): String {
return password // TODO
}
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
return password == hashedPassword // TODO
}
}

View File

@ -7,6 +7,8 @@ banking-client = "0.6.0"
kcsv = "2.2.0"
kotlinx-serializable = "1.7.1"
favre-bcrypt = "0.10.2"
klf = "1.6.1"
logback = "1.5.7"
@ -36,6 +38,8 @@ kcsv = { group = "net.codinux.csv", name = "kcsv", version.ref = "kcsv" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serializable = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serializable" }
favre-bcrypt = { group = "at.favre.lib", name = "bcrypt", version.ref = "favre-bcrypt" }
klf = { group = "net.codinux.log", name = "klf", version.ref = "klf" }
logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }