Implemented biometric authentication on Android

This commit is contained in:
dankito 2024-09-18 17:01:46 +02:00
parent 4697119c58
commit 9412f6b7f0
18 changed files with 262 additions and 64 deletions

View File

@ -91,6 +91,7 @@ kotlin {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.biometric)
implementation(libs.sqldelight.android.driver)

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:allowBackup="true"

View File

@ -1,21 +1,24 @@
package net.codinux.banking.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.fragment.app.FragmentActivity
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.BiometricAuthenticationService
import net.codinux.banking.ui.service.ImageService
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ImageService.context = this.applicationContext
AuthenticationService.biometricAuthenticationService = BiometricAuthenticationService(this)
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))

View File

@ -0,0 +1,29 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
internal var biometricAuthenticationService: BiometricAuthenticationService? = null
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
actual val supportsBiometricAuthentication: Boolean
get() = biometricAuthenticationService?.supportsBiometricAuthentication ?: false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
if (biometricAuthenticationService != null) {
biometricAuthenticationService!!.authenticate(null, authenticationResult)
} else {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}
}

View File

@ -0,0 +1,66 @@
package net.codinux.banking.ui.service
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import net.codinux.banking.ui.R
import net.codinux.banking.ui.model.AuthenticationResult
import javax.crypto.Cipher
class BiometricAuthenticationService(
private val activity: FragmentActivity
) {
private val biometricManager: BiometricManager = BiometricManager.from(activity)
private val allowedAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) BIOMETRIC_STRONG
else BIOMETRIC_STRONG or BIOMETRIC_WEAK
val supportsBiometricAuthentication: Boolean by lazy {
biometricManager.canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(cipher: Cipher?, authenticationResult: (AuthenticationResult) -> Unit) {
val executor = ContextCompat.getMainExecutor(this.activity)
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
super.onAuthenticationError(errorCode, errorString)
authenticationResult(AuthenticationResult(false, errorString.toString()))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
authenticationResult(AuthenticationResult(true))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
authenticationResult(AuthenticationResult(false))
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(this.activity.getString(R.string.activity_login_authenticate_with_biometrics_prompt))
//.setSubtitle() // TODO: add subtitle?
//.setNegativeButtonText(this.activity.getString(android.R.string.cancel)) // is not allowed when device credentials are allowed
.setAllowedAuthenticators(allowedAuthenticators)
.build()
if (cipher == null) {
biometricPrompt.authenticate(promptInfo)
} else {
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
}

View File

@ -1,3 +1,5 @@
<resources>
<string name="app_name">Bankmeister</string>
<string name="activity_login_authenticate_with_biometrics_prompt">Authentifizieren Sich sich um die App zu entsperren</string>
</resources>

View File

@ -0,0 +1,23 @@
package net.codinux.banking.ui.composables.authentification
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Fingerprint
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.service.AuthenticationService
@Composable
fun BiometricAuthenticationButton(authenticationResult: (AuthenticationResult) -> Unit) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.Center) {
Button({ AuthenticationService.authenticateWithBiometrics(authenticationResult) }, enabled = AuthenticationService.supportsBiometricAuthentication) {
Icon(Icons.Outlined.Fingerprint, "Sich mittels Biometrie authentifizieren", Modifier.size(84.dp))
}
}
}

View File

@ -0,0 +1,17 @@
package net.codinux.banking.ui.model
class AuthenticationResult(
val successful: Boolean,
val error: String? = null
) {
override fun toString(): String {
return if (successful) {
"Successful"
}
else {
"Error occurred: $error"
}
}
}

View File

@ -16,10 +16,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import bankmeister.composeapp.generated.resources.*
import bankmeister.composeapp.generated.resources.Res
import net.codinux.banking.ui.composables.authentification.BiometricAuthenticationButton
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.service.PasswordService
import net.codinux.banking.ui.service.AuthenticationService
import org.jetbrains.compose.resources.imageResource
@Composable
@ -30,9 +32,21 @@ fun LoginScreen(appSettings: AppSettings, onLoginSuccess: () -> Unit) {
var showError by remember { mutableStateOf(false) }
fun successfullyLoggedIn() {
onLoginSuccess()
}
fun checkPassword() {
if (appSettings.hashedPassword != null && PasswordService.checkPassword(password, appSettings.hashedPassword!!)) {
onLoginSuccess()
if (appSettings.hashedPassword != null && AuthenticationService.checkPassword(password, appSettings.hashedPassword!!)) {
successfullyLoggedIn()
} else {
showError = true
}
}
fun checkBiometricLoginResult(result: AuthenticationResult) {
if (result.successful) {
successfullyLoggedIn()
} else {
showError = true
}
@ -74,6 +88,26 @@ fun LoginScreen(appSettings: AppSettings, onLoginSuccess: () -> Unit) {
Text("Login", color = Color.White)
}
}
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
if (showError) {
Text("Biometrische Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", color = MaterialTheme.colors.error, fontSize = 18.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).padding(horizontal = 16.dp))
}
BiometricAuthenticationButton {
checkBiometricLoginResult(it)
}
}
}
}
LaunchedEffect(appSettings.authenticationMethod) {
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
AuthenticationService.authenticateWithBiometrics { result ->
checkBiometricLoginResult(result)
}
}
}

View File

@ -1,9 +1,7 @@
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
@ -13,6 +11,7 @@ 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.composables.authentification.BiometricAuthenticationButton
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Internationalization
@ -21,7 +20,7 @@ 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
import net.codinux.banking.ui.service.AuthenticationService
private val buttonHeight = 50.dp
@ -30,7 +29,7 @@ private val buttonHeight = 50.dp
fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
val currentAuthenticationMethod = appSettings.authenticationMethod
val isBiometricAuthenticationSupported = false
val isBiometricAuthenticationSupported = AuthenticationService.supportsBiometricAuthentication
val supportedAuthenticationMethods = buildList {
add(AppAuthenticationMethod.Password)
@ -47,35 +46,30 @@ fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
var confirmedNewPassword by remember { mutableStateOf("") }
var hasAuthenticatedWithBiometric by remember { mutableStateOf(false) }
val isRequiredDataEntered by remember(newPassword, confirmedNewPassword) {
derivedStateOf { newPassword.isNotBlank() && newPassword == confirmedNewPassword }
derivedStateOf {
(selectedAuthenticationMethod == AppAuthenticationMethod.Password && newPassword.isNotBlank() && newPassword == confirmedNewPassword)
|| (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric && hasAuthenticatedWithBiometric)
}
}
val coroutineScope = rememberCoroutineScope()
fun saveAppSettings(appSettings: AppSettings) {
fun saveNewAppProtection() {
coroutineScope.launch {
appSettings.authenticationMethod = selectedAuthenticationMethod
appSettings.hashedPassword = if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) AuthenticationService.hashPassword(newPassword)
else null
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)) {
@ -103,18 +97,24 @@ fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
PasswordTextField(confirmedNewPassword, "Password bestätigen", Modifier.padding(top = 16.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)) { confirmedNewPassword = it }
}
if (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric) {
BiometricAuthenticationButton { result ->
hasAuthenticatedWithBiometric = result.successful
}
}
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() }) {
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { saveNewAppProtection() }) {
Text("Appzugangsschutz entfernen", color = Color.White)
}
} else {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = isRequiredDataEntered,
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { setAppPasswordProtection() }) {
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { saveNewAppProtection() }) {
Text("Setzen", color = Color.White)
}
}

View File

@ -0,0 +1,16 @@
package net.codinux.banking.ui.service
import net.codinux.banking.ui.model.AuthenticationResult
expect object AuthenticationService {
fun hashPassword(password: String): String
fun checkPassword(password: String, hashedPassword: String): Boolean
val supportsBiometricAuthentication: Boolean
fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit)
}

View File

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

View File

@ -1,8 +1,9 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
import net.codinux.banking.ui.model.AuthenticationResult
actual object PasswordService {
actual object AuthenticationService {
actual fun hashPassword(password: String): String =
BCrypt.withDefaults().hashToString(12, password.toCharArray())
@ -10,4 +11,11 @@ actual object PasswordService {
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
actual val supportsBiometricAuthentication = false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}

View File

@ -1,13 +0,0 @@
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

@ -1,6 +1,8 @@
package net.codinux.banking.ui.service
actual object PasswordService {
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
// for iOS see e.g.
// https://medium.com/@mohamed.ma872/strengthening-mobile-app-security-pbkdf2-bcrypt-and-scrypt-for-android-and-ios-8089b0edbf76
@ -14,4 +16,11 @@ actual object PasswordService {
return password == hashedPassword // TODO
}
actual val supportsBiometricAuthentication = false // TODO
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
authenticationResult(AuthenticationResult(false, "Biometrics is not implemented yet"))
}
}

View File

@ -0,0 +1,22 @@
package net.codinux.banking.ui.service
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
actual fun hashPassword(password: String): String {
return password // TODO
}
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
return password == hashedPassword // TODO
}
actual val supportsBiometricAuthentication = false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}

View File

@ -1,13 +0,0 @@
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

@ -25,6 +25,7 @@ androidx-core-ktx = "1.13.1"
androidx-espresso-core = "3.6.1"
androidx-lifecycle = "2.8.0"
androidx-material = "1.12.0"
androidx-biometric = "1.1.0"
androidx-test-junit = "1.2.1"
compose-plugin = "1.6.11"
@ -59,6 +60,7 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidx-biometric" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }