From b0a7231828ba46284de20042b2e665b020a1afd6 Mon Sep 17 00:00:00 2001 From: Iulia Stana Date: Thu, 6 Apr 2023 19:34:09 +0200 Subject: [PATCH] Edured-77: Authentication flow implemented. Not available ATM: 1 - Multiple identities to select from 2 - Fallback when no network available: OTP via PIN 3 - Offer biometric upgrade when it wasn't asked for the current identity and biometric capabilities are available Known issue: showing dialogs for authentication failure --- .../main/kotlin/nl/eduid/graphs/MainGraph.kt | 78 +++++-- app/src/main/kotlin/nl/eduid/graphs/Routes.kt | 56 ++++- .../AuthenticationCompletedScreen.kt | 71 ++++++ .../AuthenticationPinBiometricScreen.kt | 217 ++++++++++++++++++ .../authorize/EduIdAuthenticationViewModel.kt | 72 ++++++ .../authorize/RequestAuthenticationScreen.kt | 121 ++++++++++ .../biometric/SignInWithBiometricsActivity.kt | 2 +- .../nl/eduid/screens/scan/ScanScreen.kt | 16 +- .../res/drawable/ic_authorize_confirmed.xml | 16 ++ app/src/main/res/values/strings.xml | 6 + 10 files changed, 622 insertions(+), 33 deletions(-) create mode 100644 app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationCompletedScreen.kt create mode 100644 app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationPinBiometricScreen.kt create mode 100644 app/src/main/kotlin/nl/eduid/screens/authorize/EduIdAuthenticationViewModel.kt create mode 100644 app/src/main/kotlin/nl/eduid/screens/authorize/RequestAuthenticationScreen.kt create mode 100644 app/src/main/res/drawable/ic_authorize_confirmed.xml diff --git a/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt b/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt index 680ca882..76505a64 100644 --- a/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt +++ b/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt @@ -10,6 +10,10 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navDeepLink import nl.eduid.screens.accountlinked.AccountLinkedScreen +import nl.eduid.screens.authorize.AuthenticationCompletedScreen +import nl.eduid.screens.authorize.AuthenticationPinBiometricScreen +import nl.eduid.screens.authorize.EduIdAuthenticationViewModel +import nl.eduid.screens.authorize.RequestAuthenticationScreen import nl.eduid.screens.biometric.EnableBiometricScreen import nl.eduid.screens.biometric.EnableBiometricViewModel import nl.eduid.screens.created.RequestEduIdCreatedScreen @@ -67,11 +71,11 @@ fun MainGraph( composable(Graph.HOME_PAGE) { val viewModel = hiltViewModel(it) HomePageScreen(viewModel = viewModel, - onScanForAuthorization = { /*QR authorization for 3rd party*/ }, + onScanForAuthorization = { navController.navigate(Account.ScanQR.routeForAuth) }, onActivityClicked = { navController.navigate(Graph.DATA_AND_ACTIVITY) }, onPersonalInfoClicked = { navController.navigate(Graph.PERSONAL_INFO) }, onSecurityClicked = { navController.navigate(Graph.SECURITY) }, - onEnrollWithQR = { navController.navigate(Account.ScanQR.route) }, + onEnrollWithQR = { navController.navigate(Account.ScanQR.routeForEnrol) }, launchOAuth = { navController.navigate(Graph.OAUTH) }) { navController.navigate( Graph.REQUEST_EDU_ID_ACCOUNT @@ -80,10 +84,13 @@ fun MainGraph( } //endregion //region Scan - composable(Account.ScanQR.route) { - val viewModel = hiltViewModel(it) + composable( + route = Account.ScanQR.routeWithArgs, arguments = Account.ScanQR.arguments + ) { entry -> + val viewModel = hiltViewModel(entry) + val isEnrolment = entry.arguments?.getBoolean(Account.ScanQR.isEnrolment, false) ?: false ScanScreen(viewModel = viewModel, - isRegistration = true, + isEnrolment = isEnrolment, goBack = { navController.popBackStack() }, goToNext = { challenge -> val encodedChallenge = viewModel.encodeChallenge(challenge) @@ -93,7 +100,7 @@ fun MainGraph( ) } else { navController.goToWithPopCurrent( - "${Account.Authorize.route}/$encodedChallenge" + "${Account.RequestAuthentication.route}/$encodedChallenge" ) } }) @@ -137,11 +144,43 @@ fun MainGraph( promptAuth = { navController.navigate(Graph.OAUTH) }) } //endregion - //region Authorize + + //region Authentication + composable( + route = Account.RequestAuthentication.routeWithArgs, + arguments = Account.RequestAuthentication.arguments, + ) { entry -> + val viewModel = hiltViewModel(entry) + RequestAuthenticationScreen(viewModel = viewModel, onLogin = { challenge -> + if (challenge != null) { + val encodedChallenge = viewModel.encodeChallenge(challenge) + navController.goToWithPopCurrent("${Account.AuthenticationCheckSecret.route}/$encodedChallenge") + } + }) { navController.popBackStack() } + } composable( - route = Account.Authorize.routeWithArgs, - arguments = Account.Authorize.arguments, + route = Account.AuthenticationCheckSecret.routeWithArgs, + arguments = Account.AuthenticationCheckSecret.arguments, ) { entry -> + val viewModel = hiltViewModel(entry) + AuthenticationPinBiometricScreen(viewModel = viewModel, + goToAuthenticationComplete = { challenge, pin -> + if (challenge != null) { + val encodedChallenge = viewModel.encodeChallenge(challenge) + navController.goToWithPopCurrent( + Account.AuthenticationCompleted.buildRoute( + encodedChallenge = encodedChallenge, pin = pin + ) + ) + } + }, + goHome = { navController.goToWithPopCurrent(Graph.HOME_PAGE) }) { navController.popBackStack() } + } + composable( + route = Account.AuthenticationCompleted.routeWithArgs, + arguments = Account.AuthenticationCompleted.arguments, + ) { _ -> + AuthenticationCompletedScreen { navController.goToWithPopCurrent(Graph.HOME_PAGE) } } //endregion @@ -162,7 +201,7 @@ fun MainGraph( navController.goToWithPopCurrent("${Account.EnrollPinSetup.route}/$encodedChallenge") } else { navController.goToWithPopCurrent( - "${Account.Authorize.route}/$encodedChallenge" + "${Account.RequestAuthentication.route}/$encodedChallenge" ) } }) @@ -219,12 +258,11 @@ fun MainGraph( ) } composable( - route = RequestEduIdCreated.route, deepLinks = listOf( - navDeepLink { - uriPattern = RequestEduIdCreated.uriPatternHttps - }, navDeepLink { - uriPattern = RequestEduIdCreated.uriPatternCustomScheme - }) + route = RequestEduIdCreated.route, deepLinks = listOf(navDeepLink { + uriPattern = RequestEduIdCreated.uriPatternHttps + }, navDeepLink { + uriPattern = RequestEduIdCreated.uriPatternCustomScheme + }) ) { entry -> val viewModel = hiltViewModel(entry) RequestEduIdCreatedScreen( @@ -330,7 +368,13 @@ fun MainGraph( onEmailClicked = { navController.navigate(Graph.EDIT_EMAIL) }, onRoleClicked = { }, onInstitutionClicked = { }, - onManageAccountClicked = { dateString -> navController.navigate(ManageAccountRoute.routeWithArgs(dateString)) }, + onManageAccountClicked = { dateString -> + navController.navigate( + ManageAccountRoute.routeWithArgs( + dateString + ) + ) + }, goBack = { navController.popBackStack() }, ) } diff --git a/app/src/main/kotlin/nl/eduid/graphs/Routes.kt b/app/src/main/kotlin/nl/eduid/graphs/Routes.kt index 6aa4cf8e..b8204577 100644 --- a/app/src/main/kotlin/nl/eduid/graphs/Routes.kt +++ b/app/src/main/kotlin/nl/eduid/graphs/Routes.kt @@ -3,7 +3,6 @@ package nl.eduid.graphs import androidx.navigation.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.navArgument -import nl.eduid.graphs.Graph.MANAGE_ACCOUNT import java.io.UnsupportedEncodingException import java.net.URLDecoder import java.net.URLEncoder @@ -23,7 +22,6 @@ object Graph { const val RESET_PASSWORD_CONFIRM = "reset_password_confirm" const val EDIT_EMAIL = "edit_email" const val TWO_FA_DETAIL = "2fa_detail" - const val MANAGE_ACCOUNT = "manage_account" const val DELETE_ACCOUNT_FIRST_CONFIRM = "delete_account_first_confirm" const val DELETE_ACCOUNT_SECOND_CONFIRM = "delete_account_second_confirm" } @@ -97,7 +95,19 @@ sealed class PhoneNumberRecovery(val route: String) { sealed class Account(val route: String) { - object ScanQR : Account("scan") + object ScanQR : Account("scan") { + const val isEnrolment = "is_enrolment" + val routeWithArgs = "$route/{${isEnrolment}}" + val routeForEnrol = "$route/true" + val routeForAuth = "$route/false" + val arguments = listOf(navArgument(isEnrolment) { + type = NavType.BoolType + nullable = false + defaultValue = true + }) + + } + object EnrollPinSetup : Account("enroll_pin_setup") { const val enrollChallenge = "enroll_challenge_arg" @@ -109,7 +119,7 @@ sealed class Account(val route: String) { }) } - object Authorize : Account("authorization") { + object RequestAuthentication : Account("authentication") { const val challengeArg = "challenge_arg" val routeWithArgs = "$route/{$challengeArg}" @@ -120,6 +130,39 @@ sealed class Account(val route: String) { }) } + object AuthenticationCheckSecret : Account("authentication_checksecret") { + private const val challengeArg = "challenge_arg" + + val routeWithArgs = "$route/{$challengeArg}" + val arguments = listOf(navArgument(challengeArg) { + type = NavType.StringType + nullable = false + defaultValue = "" + }) + } + + object AuthenticationCompleted : Account("authentication_completed") { + private const val challengeArg = "challenge_arg" + private const val pinArg = "pin_arg" + + val routeWithArgs = "$route/{$challengeArg}?pin={$pinArg}" + val arguments = listOf(navArgument(challengeArg) { + type = NavType.StringType + nullable = false + defaultValue = "" + }, navArgument(pinArg) { + type = NavType.StringType + nullable = true + }) + + fun buildRoute(encodedChallenge: String, pin: String?): String = if (pin.isNullOrEmpty()) { + "${route}/$encodedChallenge" + } else { + "${route}/$encodedChallenge?pin=$pin" + } + + } + //https://eduid.nl/tiqrenroll/?metadata=https%3A%2F%2Flogin.test2.eduid.nl%2Ftiqr%2Fmetadata%3Fenrollment_key%3Dd47fa31400084edc043f8c547c5ed3f6b18d69f5a71f422519911f034b865f96153c8fc1507d81bc05aba95d095489a8d0400909f8aab348e2ac1786b28db572 object DeepLink : Account("deeplinks") { const val enrollPattern = "https://eduid.nl/tiqrenroll/?metadata=" @@ -161,11 +204,10 @@ sealed class WithChallenge(val route: String) { } object ManageAccountRoute { - private const val route = MANAGE_ACCOUNT + private const val route = "manage_account" const val dateArg = "date_arg" const val routeWithArgs = "$route/{$dateArg}" - val arguments = listOf( - navArgument(dateArg) { + val arguments = listOf(navArgument(dateArg) { type = NavType.StringType nullable = false defaultValue = "" diff --git a/app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationCompletedScreen.kt b/app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationCompletedScreen.kt new file mode 100644 index 00000000..8d0cdfe1 --- /dev/null +++ b/app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationCompletedScreen.kt @@ -0,0 +1,71 @@ +package nl.eduid.screens.authorize + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nl.eduid.R +import nl.eduid.ui.EduIdTopAppBar +import nl.eduid.ui.PrimaryButton +import nl.eduid.ui.theme.EduidAppAndroidTheme +import nl.eduid.ui.theme.TextGreen + +@Composable +fun AuthenticationCompletedScreen(goHome: () -> Unit = {}) = EduIdTopAppBar( + withBackIcon = false +) { + Column(modifier = Modifier.fillMaxSize()) { + Text( + style = MaterialTheme.typography.titleLarge.copy( + textAlign = TextAlign.Start, color = TextGreen + ), text = stringResource(R.string.authorize_title), modifier = Modifier.fillMaxWidth() + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.ic_authorize_confirmed), + contentDescription = "", + modifier = Modifier.wrapContentSize(), + alignment = Alignment.Center + ) + + Text( + text = stringResource(R.string.authorize_confirmed_subtitle), + style = MaterialTheme.typography.titleLarge.copy( + color = TextGreen + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.button_ok), + onClick = goHome, + ) + Spacer(Modifier.height(24.dp)) + } +} + +@Preview +@Composable +private fun PreviewAuthorizeConfirmedScreen() = EduidAppAndroidTheme { + AuthenticationCompletedScreen() +} \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationPinBiometricScreen.kt b/app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationPinBiometricScreen.kt new file mode 100644 index 00000000..97955ec4 --- /dev/null +++ b/app/src/main/kotlin/nl/eduid/screens/authorize/AuthenticationPinBiometricScreen.kt @@ -0,0 +1,217 @@ +package nl.eduid.screens.authorize + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nl.eduid.R +import nl.eduid.screens.biometric.BiometricSignIn +import nl.eduid.screens.biometric.SignInWithBiometricsContract +import nl.eduid.ui.AlertDialogWithSingleButton +import nl.eduid.ui.EduIdTopAppBar +import nl.eduid.ui.PinInputField +import nl.eduid.ui.PrimaryButton +import nl.eduid.ui.SecondaryButton +import nl.eduid.ui.theme.EduidAppAndroidTheme +import nl.eduid.ui.theme.TextGreen +import org.tiqr.core.util.extensions.biometricUsable +import org.tiqr.data.model.AuthenticationChallenge +import org.tiqr.data.model.AuthenticationCompleteFailure +import org.tiqr.data.model.ChallengeCompleteFailure +import org.tiqr.data.model.ChallengeCompleteResult +import timber.log.Timber + +@Composable +fun AuthenticationPinBiometricScreen( + viewModel: EduIdAuthenticationViewModel, + goToAuthenticationComplete: (AuthenticationChallenge?, String) -> Unit, + goHome: () -> Unit, + onCancel: () -> Unit, +) = EduIdTopAppBar( + withBackIcon = false +) { + val authChallenge by viewModel.challenge.observeAsState(null) + val challengeComplete by viewModel.challengeComplete.observeAsState(null) + val context = LocalContext.current + + AuthenticationPinBiometricContent( + shouldPromptBiometric = context.biometricUsable() && authChallenge?.identity?.biometricInUse == true, + challengeComplete = challengeComplete, + onBiometricResult = { biometricSignIn -> + viewModel.authenticateWithBiometric(biometricSignIn) + }, + submitPin = { pin -> + viewModel.authenticateWithPin(pin) + }, + onCancel = onCancel, + goToAuthenticationComplete = { pin -> + goToAuthenticationComplete(authChallenge, pin) + }, + clearCompleteChallenge = viewModel::clearCompleteChallenge, + goHome = goHome + ) +} + +@Composable +private fun AuthenticationPinBiometricContent( + shouldPromptBiometric: Boolean, + challengeComplete: ChallengeCompleteResult? = null, + isPinInvalid: Boolean = false, + onBiometricResult: (BiometricSignIn) -> Unit = {}, + submitPin: (String) -> Unit = {}, + onCancel: () -> Unit = {}, + goToAuthenticationComplete: (String) -> Unit = {}, + clearCompleteChallenge: () -> Unit = {}, + goHome: () -> Unit = {}, +) { + var isCheckingSecret by rememberSaveable { mutableStateOf(false) } + var pinValue by rememberSaveable { mutableStateOf("") } + val owner = LocalLifecycleOwner.current + + if (isCheckingSecret && challengeComplete != null) { + when (challengeComplete) { + is ChallengeCompleteResult.Failure -> { + val failure = if (challengeComplete.failure is AuthenticationCompleteFailure) { + challengeComplete.failure as AuthenticationCompleteFailure + } else { + return + } + isCheckingSecret = false + when (failure.reason) { + AuthenticationCompleteFailure.Reason.UNKNOWN, + AuthenticationCompleteFailure.Reason.CONNECTION, + -> { + Timber.e("This should be a fallback to OTP") + TODO() + } + + AuthenticationCompleteFailure.Reason.INVALID_RESPONSE -> { + val remaining = failure.remainingAttempts + if (remaining == null || remaining > 0) { + pinValue = "" + } + AlertDialogWithSingleButton(title = failure.title, + explanation = failure.message, + buttonLabel = stringResource(R.string.button_ok), + onDismiss = { + if (remaining != null && remaining == 0) { + goHome() + } + clearCompleteChallenge.invoke() + }) + } + + else -> { + AlertDialogWithSingleButton( + title = failure.title, + explanation = failure.message, + buttonLabel = stringResource(R.string.button_ok), + onDismiss = clearCompleteChallenge + ) + } + } + } + + ChallengeCompleteResult.Success -> { + val currentGoToAuthenticationComplete by rememberUpdatedState( + goToAuthenticationComplete + ) + LaunchedEffect(owner) { + isCheckingSecret = false + currentGoToAuthenticationComplete(pinValue) + } + } + } + } + + if (shouldPromptBiometric) { + var hasAutoRequestedBiometrics by remember { + mutableStateOf(false) + } + val launchBiometricSignIn = rememberLauncherForActivityResult( + contract = SignInWithBiometricsContract(), + ) { result -> + isCheckingSecret = true + onBiometricResult(result) + } + if (!hasAutoRequestedBiometrics) { + SideEffect { + hasAutoRequestedBiometrics = true + launchBiometricSignIn.launch(Unit) + } + } + } + Column(modifier = Modifier.fillMaxSize()) { + Text( + style = MaterialTheme.typography.titleLarge.copy( + textAlign = TextAlign.Start, color = TextGreen + ), text = stringResource(R.string.auth_pin_title), modifier = Modifier.fillMaxWidth() + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + PinInputField(label = stringResource(R.string.auth_pin_subtitle), + pinCode = pinValue, + isPinInvalid = isPinInvalid, + modifier = Modifier.fillMaxWidth(), + onPinChange = { newValue -> pinValue = newValue }, + submitPin = { + isCheckingSecret = true + submitPin(pinValue) + }) + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + modifier = Modifier.widthIn(min = 140.dp), + text = stringResource(R.string.button_cancel), + onClick = onCancel, + ) + PrimaryButton( + modifier = Modifier.widthIn(min = 140.dp), + text = stringResource(R.string.button_ok), + onClick = { + isCheckingSecret = true + submitPin(pinValue) + }, + ) + } + Spacer(Modifier.height(24.dp)) + + } +} + +@Preview +@Composable +private fun PreviewAuthorizePinBiometricScreen() = EduidAppAndroidTheme { + AuthenticationPinBiometricContent( + shouldPromptBiometric = false, + isPinInvalid = false, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/authorize/EduIdAuthenticationViewModel.kt b/app/src/main/kotlin/nl/eduid/screens/authorize/EduIdAuthenticationViewModel.kt new file mode 100644 index 00000000..5c12ec2f --- /dev/null +++ b/app/src/main/kotlin/nl/eduid/screens/authorize/EduIdAuthenticationViewModel.kt @@ -0,0 +1,72 @@ +package nl.eduid.screens.authorize + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.squareup.moshi.Moshi +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import nl.eduid.BaseViewModel +import nl.eduid.graphs.Account +import nl.eduid.screens.biometric.BiometricSignIn +import org.tiqr.data.model.AuthenticationChallenge +import org.tiqr.data.model.AuthenticationCompleteRequest +import org.tiqr.data.model.ChallengeCompleteFailure +import org.tiqr.data.model.ChallengeCompleteResult +import org.tiqr.data.model.SecretCredential +import org.tiqr.data.repository.AuthenticationRepository +import timber.log.Timber +import java.net.URLDecoder +import javax.inject.Inject + +@HiltViewModel +class EduIdAuthenticationViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + moshi: Moshi, + val repository: AuthenticationRepository, +) : BaseViewModel(moshi) { + + val challenge = MutableLiveData() + val challengeComplete = + MutableLiveData?>(null) + + init { + val authorizeChallenge = + savedStateHandle.get(Account.RequestAuthentication.challengeArg) ?: "" + val challengeUrl = URLDecoder.decode(authorizeChallenge, Charsets.UTF_8.name()) + val adapter = moshi.adapter(AuthenticationChallenge::class.java) + challenge.value = try { + adapter.fromJson(challengeUrl) + } catch (e: Exception) { + Timber.e(e, "Failed to parse enrollment challenge") +// uiState.value = uiState.value?.copy( +// errorData = ErrorData( +// title = "Failed to parse challenge", +// message = "Could not parse enrollment challenge" +// ) +// ) + null + } + + } + + fun clearCompleteChallenge() { + challengeComplete.value = null + } + + fun authenticateWithPin(pin: String) = authenticate(SecretCredential.pin(pin)) + fun authenticateWithBiometric(biometricSignIn: BiometricSignIn) { + if (biometricSignIn is BiometricSignIn.Success) authenticate(SecretCredential.biometric()) + } + + private fun authenticate(credential: SecretCredential) = viewModelScope.launch { + val it = challenge.value ?: return@launch + val challengeRequest = + AuthenticationCompleteRequest(it, credential.password, credential.type) + val challengeResult: ChallengeCompleteResult = + repository.completeChallenge(challengeRequest) + challengeComplete.postValue(challengeResult) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/authorize/RequestAuthenticationScreen.kt b/app/src/main/kotlin/nl/eduid/screens/authorize/RequestAuthenticationScreen.kt new file mode 100644 index 00000000..438d96eb --- /dev/null +++ b/app/src/main/kotlin/nl/eduid/screens/authorize/RequestAuthenticationScreen.kt @@ -0,0 +1,121 @@ +package nl.eduid.screens.authorize + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nl.eduid.R +import nl.eduid.ui.EduIdTopAppBar +import nl.eduid.ui.PrimaryButton +import nl.eduid.ui.SecondaryButton +import nl.eduid.ui.theme.EduidAppAndroidTheme +import nl.eduid.ui.theme.TextGreen +import org.tiqr.data.model.AuthenticationChallenge + +@Composable +fun RequestAuthenticationScreen( + viewModel: EduIdAuthenticationViewModel, + onLogin: (AuthenticationChallenge?) -> Unit, + onCancel: () -> Unit, +) = EduIdTopAppBar( + withBackIcon = false +) { + val authChallenge by viewModel.challenge.observeAsState(null) + RequestAuthenticationContent( + loginToService = authChallenge?.serviceProviderDisplayName, + onLogin = { onLogin(authChallenge) }, + onCancel = onCancel + ) +} + +@Composable +private fun RequestAuthenticationContent( + loginToService: String?, + onLogin: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + if (loginToService == null) { + + + } else { + Column(modifier = Modifier.fillMaxSize()) { + Text( + style = MaterialTheme.typography.titleLarge.copy( + textAlign = TextAlign.Start, color = TextGreen + ), + text = stringResource(R.string.authorize_title), + modifier = Modifier.fillMaxWidth() + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + val loginQuestion = buildAnnotatedString { + pushStyle( + MaterialTheme.typography.titleLarge.copy( + color = TextGreen + ).toSpanStyle() + ) + append(stringResource(R.string.authorize_subtitle01)) + pop() + append("\n") + pushStyle( + MaterialTheme.typography.titleLarge.copy( + textAlign = TextAlign.Center + ).toSpanStyle() + ) + append( + stringResource( + R.string.authorize_subtitle02, loginToService + ) + ) + } + + Text( + text = loginQuestion, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center + ) + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + modifier = Modifier.widthIn(min = 140.dp), + text = stringResource(R.string.button_cancel), + onClick = onCancel, + ) + PrimaryButton( + modifier = Modifier.widthIn(min = 140.dp), + text = stringResource(R.string.authorize_login_button), + onClick = onLogin, + ) + } + Spacer(Modifier.height(24.dp)) + } + } +} + +@Preview +@Composable +private fun PreviewRequestAuthorizeContent() = EduidAppAndroidTheme { + RequestAuthenticationContent("3rd party service") +} \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/biometric/SignInWithBiometricsActivity.kt b/app/src/main/kotlin/nl/eduid/screens/biometric/SignInWithBiometricsActivity.kt index 68e8748e..aa27868b 100644 --- a/app/src/main/kotlin/nl/eduid/screens/biometric/SignInWithBiometricsActivity.kt +++ b/app/src/main/kotlin/nl/eduid/screens/biometric/SignInWithBiometricsActivity.kt @@ -58,7 +58,7 @@ class SignInWithBiometricsActivity : AppCompatActivity() { private fun createPromptInfo(): BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder().apply { - setTitle(getString(R.string.auth_biometric_title)) + setTitle(getString(R.string.auth_biometric_dialog_title)) setConfirmationRequired(false) setNegativeButtonText(getString(R.string.auth_biometric_dialog_cancel)) }.build() diff --git a/app/src/main/kotlin/nl/eduid/screens/scan/ScanScreen.kt b/app/src/main/kotlin/nl/eduid/screens/scan/ScanScreen.kt index 802131a2..c3370ee9 100644 --- a/app/src/main/kotlin/nl/eduid/screens/scan/ScanScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/scan/ScanScreen.kt @@ -36,7 +36,7 @@ import androidx.camera.core.Preview as CameraPreview @Composable fun ScanScreen( viewModel: StatelessScanViewModel, - isRegistration: Boolean, + isEnrolment: Boolean, goBack: () -> Unit, goToNext: (Challenge) -> Unit, state: ScanState = rememberScanState( @@ -91,7 +91,7 @@ fun ScanScreen( }, ) { paddingValues -> ScanContent( - isRegistration = isRegistration, + isEnrolment = isEnrolment, hasCamPermission = state.hasCamPermission, camPermissionUpdated = { state.camPermissionUpdated(it) }, errorData = state.errorData, @@ -106,7 +106,7 @@ fun ScanScreen( @Composable private fun ScanContent( - isRegistration: Boolean, + isEnrolment: Boolean, hasCamPermission: Boolean, camPermissionUpdated: (Boolean) -> Unit, errorData: ErrorData?, @@ -193,7 +193,7 @@ private fun ScanContent( top.linkTo(contentTopSpacing) }) } - if (!isRegistration) { + if (!isEnrolment) { Text(text = stringResource(R.string.scan_title), style = MaterialTheme.typography.titleLarge.copy( textAlign = TextAlign.Center, color = Color.White @@ -205,7 +205,7 @@ private fun ScanContent( }) } - if (isRegistration) { + if (isEnrolment) { RegistrationExplanation( modifier = Modifier .fillMaxWidth() @@ -222,7 +222,7 @@ private fun ScanContent( @Composable private fun Preview_ScanScreen_Registration() { EduidAppAndroidTheme { - ScanContent(isRegistration = true, + ScanContent(isEnrolment = true, hasCamPermission = true, camPermissionUpdated = {}, errorData = null, @@ -237,7 +237,7 @@ private fun Preview_ScanScreen_Registration() { @Composable private fun Preview_ScanScreen_MissingCamPermission() { EduidAppAndroidTheme { - ScanContent(isRegistration = true, + ScanContent(isEnrolment = true, hasCamPermission = false, camPermissionUpdated = {}, errorData = null, @@ -252,7 +252,7 @@ private fun Preview_ScanScreen_MissingCamPermission() { @Composable private fun Preview_ScanScreen_Authentication() { EduidAppAndroidTheme { - ScanContent(isRegistration = false, + ScanContent(isEnrolment = false, hasCamPermission = true, camPermissionUpdated = {}, errorData = null, diff --git a/app/src/main/res/drawable/ic_authorize_confirmed.xml b/app/src/main/res/drawable/ic_authorize_confirmed.xml new file mode 100644 index 00000000..3d92c41d --- /dev/null +++ b/app/src/main/res/drawable/ic_authorize_confirmed.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61ef7ae4..a1c75393 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,12 @@ was contacted successfully The following information has been added to your eduID and can now be shared. + Request to log on + Do you want to login to + %s? + Login + You are logged on + Checking request… Back