From cb74b0571a87530f6ffb6bf3c69bb8899ad1e9f9 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 24 Feb 2026 21:33:00 +0100 Subject: [PATCH] Improve authenticateUser flow (#1776) --- .../java/net/aliasvault/app/MainActivity.kt | 52 ++++++-- .../nativevaultmanager/NativeVaultManager.kt | 95 +++++++++++--- .../(tabs)/settings/mobile-unlock/[id].tsx | 4 +- .../ios/NativeVaultManager/VaultManager.swift | 122 +++++++++++++++--- 4 files changed, 228 insertions(+), 45 deletions(-) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt index f31d1a9f6..a3a592742 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt @@ -133,6 +133,8 @@ class MainActivity : ReactActivity() { net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingActivityResultPromise = null if (promise == null) { + // Clear auth context even if promise is null + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext = null return } @@ -143,6 +145,8 @@ class MainActivity : ReactActivity() { when (resultCode) { net.aliasvault.app.pinunlock.PinUnlockActivity.RESULT_SUCCESS -> { + // Clear auth context on success + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext = null val encryptionKeyBase64 = data?.getStringExtra( net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_ENCRYPTION_KEY, ) @@ -171,12 +175,32 @@ class MainActivity : ReactActivity() { } } net.aliasvault.app.pinunlock.PinUnlockActivity.RESULT_CANCELLED -> { - promise.reject("USER_CANCELLED", "User cancelled PIN unlock", null) + // Check if password fallback is available (from authenticateUser flow) + val authContext = net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext + if (authContext != null) { + // Password fallback is available - launch password unlock + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext = null + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingActivityResultPromise = promise + + val intent = Intent(this, net.aliasvault.app.passwordunlock.PasswordUnlockActivity::class.java) + if (!authContext.first.isNullOrEmpty()) { + intent.putExtra(net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_CUSTOM_TITLE, authContext.first) + } + if (!authContext.second.isNullOrEmpty()) { + intent.putExtra(net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_CUSTOM_SUBTITLE, authContext.second) + } + startActivityForResult(intent, net.aliasvault.app.nativevaultmanager.NativeVaultManager.PASSWORD_UNLOCK_REQUEST_CODE) + } else { + // No fallback - reject promise + promise.reject("USER_CANCELLED", "User cancelled PIN unlock", null) + } } net.aliasvault.app.pinunlock.PinUnlockActivity.RESULT_PIN_DISABLED -> { + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext = null promise.reject("PIN_DISABLED", "PIN was disabled", null) } else -> { + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext = null promise.reject("UNKNOWN_ERROR", "Unknown error in PIN unlock", null) } } @@ -217,25 +241,35 @@ class MainActivity : ReactActivity() { * @param data The intent data containing the encryption key. */ private fun handlePasswordUnlockResult(resultCode: Int, data: Intent?) { - val promise = net.aliasvault.app.nativevaultmanager.NativeVaultManager.passwordUnlockPromise - net.aliasvault.app.nativevaultmanager.NativeVaultManager.passwordUnlockPromise = null + // Check both promise types - one for showPasswordUnlock() and one for authenticateUser() + val passwordPromise = net.aliasvault.app.nativevaultmanager.NativeVaultManager.passwordUnlockPromise + val authPromise = net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingActivityResultPromise - if (promise == null) { - return - } + net.aliasvault.app.nativevaultmanager.NativeVaultManager.passwordUnlockPromise = null + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingActivityResultPromise = null + net.aliasvault.app.nativevaultmanager.NativeVaultManager.pendingAuthContext = null when (resultCode) { net.aliasvault.app.passwordunlock.PasswordUnlockActivity.RESULT_SUCCESS -> { val encryptionKeyBase64 = data?.getStringExtra( net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_ENCRYPTION_KEY, ) - promise.resolve(encryptionKeyBase64) + // For showPasswordUnlock(), resolve with encryption key + passwordPromise?.resolve(encryptionKeyBase64) + // For authenticateUser(), resolve with boolean success + authPromise?.resolve(true) } net.aliasvault.app.passwordunlock.PasswordUnlockActivity.RESULT_CANCELLED -> { - promise.resolve(null) + // For showPasswordUnlock(), resolve with null + passwordPromise?.resolve(null) + // For authenticateUser(), resolve with false + authPromise?.resolve(false) } else -> { - promise.resolve(null) + // For showPasswordUnlock(), resolve with null + passwordPromise?.resolve(null) + // For authenticateUser(), resolve with false + authPromise?.resolve(false) } } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index a1299ed76..d23305501 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -96,6 +96,13 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : */ @Volatile var passwordUnlockPromise: Promise? = null + + /** + * Static holder for authentication context (title, subtitle) to support password fallback + * when PIN is cancelled in authenticateUser flow. + */ + @Volatile + var pendingAuthContext: Pair? = null } private val vaultStore = VaultStore.getInstance( @@ -1492,19 +1499,46 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : override fun authenticateUser(title: String?, subtitle: String?, promise: Promise) { CoroutineScope(Dispatchers.Main).launch { try { - // Check if PIN is enabled first + // Get enabled authentication methods + val authMethods = vaultStore.getAuthMethods() + val isBiometricEnabled = authMethods.contains("faceid") val pinEnabled = vaultStore.isPinEnabled() + // Check if password auth is available as fallback + val hasPasswordFallback = authMethods.contains("password") + + // If PIN is enabled, prefer PIN (with biometric first if available) if (pinEnabled) { - // PIN is enabled, show PIN unlock UI + // Try biometric authentication first if enabled + if (isBiometricEnabled) { + try { + val authenticated = vaultStore.issueBiometricAuthentication(title) + if (authenticated) { + promise.resolve(true) + return@launch + } + // Biometric failed or cancelled - fall through to PIN fallback + } catch (e: Exception) { + Log.d(TAG, "Biometric authentication failed or cancelled, trying PIN fallback", e) + // Fall through to PIN fallback + } + } + + // Show PIN unlock (either as primary or fallback) try { // Store promise for later resolution by MainActivity pendingActivityResultPromise = promise + // Store auth context in case PIN is cancelled and we need to fall back to password + if (hasPasswordFallback) { + pendingAuthContext = Pair(title, subtitle) + } + // Launch PIN unlock activity val activity = currentActivity if (activity == null) { promise.reject("NO_ACTIVITY", "No activity available", null) + pendingAuthContext = null return@launch } @@ -1517,33 +1551,56 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_CUSTOM_SUBTITLE, subtitle) } activity.startActivityForResult(intent, PIN_UNLOCK_REQUEST_CODE) + return@launch } catch (e: Exception) { Log.e(TAG, "PIN authentication failed", e) promise.reject("AUTH_ERROR", "PIN authentication failed: ${e.message}", e) + pendingAuthContext = null + return@launch } - } else { - // Use biometric authentication + } + + // No PIN enabled - check for biometric + password + if (isBiometricEnabled) { try { val authenticated = vaultStore.issueBiometricAuthentication(title) - if (!authenticated) { - // Biometric failed - reject with error instead of resolving false - promise.reject( - "AUTH_ERROR", - "No authentication method available. Please enable PIN or biometric unlock in settings.", - null, - ) - } else { - promise.resolve(authenticated) + if (authenticated) { + promise.resolve(true) + return@launch } + // Biometric failed or cancelled - fall through to password fallback if available } catch (e: Exception) { - Log.e(TAG, "Biometric authentication failed", e) - promise.reject( - "AUTH_ERROR", - "Biometric authentication failed: ${e.message}", - e, - ) + Log.d(TAG, "Biometric authentication failed or cancelled, trying password fallback", e) + // Fall through to password fallback } } + + // Show password unlock (either as primary or fallback from biometric) + try { + // Store promise for later resolution by MainActivity + // Use pendingActivityResultPromise to match PIN unlock behavior (returns boolean) + pendingActivityResultPromise = promise + + // Launch password unlock activity + val activity = currentActivity + if (activity == null) { + promise.reject("NO_ACTIVITY", "No activity available", null) + return@launch + } + + val intent = Intent(activity, net.aliasvault.app.passwordunlock.PasswordUnlockActivity::class.java) + // Add custom title/subtitle if provided + if (!title.isNullOrEmpty()) { + intent.putExtra(net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_CUSTOM_TITLE, title) + } + if (!subtitle.isNullOrEmpty()) { + intent.putExtra(net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_CUSTOM_SUBTITLE, subtitle) + } + activity.startActivityForResult(intent, PASSWORD_UNLOCK_REQUEST_CODE) + } catch (e: Exception) { + Log.e(TAG, "Password authentication failed", e) + promise.reject("AUTH_ERROR", "Password authentication failed: ${e.message}", e) + } } catch (e: Exception) { Log.e(TAG, "Authentication failed", e) promise.reject("AUTH_ERROR", "Authentication failed: ${e.message}", e) diff --git a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx index 64d6a9f8f..2048506b8 100644 --- a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx +++ b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx @@ -158,7 +158,9 @@ export default function MobileUnlockConfirmScreen() : React.ReactNode { setIsProcessing(true); try { - // Authenticate user with either biometric or PIN (automatically detected) + /* + * Mobile unlock is a security-sensitive operation that requires re-authentication. + */ const authenticated = await VaultUnlockHelper.authenticateForAction( t('settings.qrScanner.mobileLogin.confirmTitle'), t('settings.qrScanner.mobileLogin.confirmSubtitle') diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index d32c2d5a1..8aa178d0c 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -969,20 +969,40 @@ public class VaultManager: NSObject { subtitle: String?, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { - // Check if PIN is enabled first + // Get enabled authentication methods + let authMethods = vaultStore.getAuthMethods() + let isBiometricEnabled = authMethods.contains(.faceID) let pinEnabled = vaultStore.isPinEnabled() + // If PIN is enabled, prefer PIN (with biometric first if available) if pinEnabled { - // PIN is enabled, show PIN unlock UI + // Try biometric authentication first if enabled + if isBiometricEnabled { + let authenticated = vaultStore.issueBiometricAuthentication(title: title) + if authenticated { + resolve(true) + return + } + // Biometric failed or cancelled - fall through to PIN fallback + } + + // Show PIN unlock (either as primary or fallback) + // Create a semaphore to handle the async PIN unlock result + let semaphore = DispatchSemaphore(value: 0) + var pinUnlockSucceeded = false + var pinWasCancelled = false + DispatchQueue.main.async { [weak self] in guard let self = self else { reject("INTERNAL_ERROR", "VaultManager instance deallocated", nil) + semaphore.signal() return } // Get the root view controller from React Native guard let rootVC = RCTPresentedViewController() else { reject("NO_VIEW_CONTROLLER", "No view controller available", nil) + semaphore.signal() return } @@ -1005,14 +1025,17 @@ public class VaultManager: NSObject { // Success - dismiss and resolve await MainActor.run { rootVC.dismiss(animated: true) { + pinUnlockSucceeded = true + semaphore.signal() resolve(true) } } }, cancelHandler: { - // User cancelled - dismiss and resolve with false + // User cancelled PIN - check if password fallback is available rootVC.dismiss(animated: true) { - resolve(false) + pinWasCancelled = true + semaphore.signal() } } ) @@ -1024,19 +1047,86 @@ public class VaultManager: NSObject { hostingController.modalPresentationStyle = .fullScreen rootVC.present(hostingController, animated: true) } - } else { - // Use biometric authentication - let authenticated = vaultStore.issueBiometricAuthentication(title: title) - if !authenticated { - // Biometric failed - reject with error instead of resolving false - reject( - "AUTH_ERROR", - "No authentication method available. Please enable PIN or biometric unlock in settings.", - nil - ) - } else { - resolve(authenticated) + + // Wait for PIN unlock to complete or be cancelled + semaphore.wait() + + // If PIN succeeded, we're done (already resolved above) + if pinUnlockSucceeded { + return } + + // If PIN was cancelled and password auth is available, fall back to password + if pinWasCancelled && authMethods.contains(.password) { + // Fall through to password unlock below + } else { + // No password fallback available, resolve with false + resolve(false) + return + } + } + + // No PIN enabled - check for biometric + password + if isBiometricEnabled { + let authenticated = vaultStore.issueBiometricAuthentication(title: title) + if authenticated { + resolve(true) + return + } + // Biometric failed or cancelled - fall through to password fallback if available + } + + // Show password unlock (either as primary or fallback from biometric) + DispatchQueue.main.async { [weak self] in + guard let self = self else { + reject("INTERNAL_ERROR", "VaultManager instance deallocated", nil) + return + } + + // Get the root view controller from React Native + guard let rootVC = RCTPresentedViewController() else { + reject("NO_VIEW_CONTROLLER", "No view controller available", nil) + return + } + + // Create password unlock view with ViewModel + // Use custom title/subtitle if provided, otherwise use defaults + let customTitle = (title?.isEmpty == false) ? title : nil + let customSubtitle = (subtitle?.isEmpty == false) ? subtitle : nil + let viewModel = PasswordUnlockViewModel( + customTitle: customTitle, + customSubtitle: customSubtitle, + unlockHandler: { [weak self] password in + guard let self = self else { + throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "VaultManager instance deallocated"]) + } + + // Verify password and get encryption key + guard let _ = try self.vaultStore.verifyPassword(password) else { + throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Incorrect password"]) + } + + // Success - dismiss and resolve + await MainActor.run { + rootVC.dismiss(animated: true) { + resolve(true) + } + } + }, + cancelHandler: { + // User cancelled - dismiss and resolve with false + rootVC.dismiss(animated: true) { + resolve(false) + } + } + ) + + let passwordView = PasswordUnlockView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: passwordView) + + // Present modally as full screen + hostingController.modalPresentationStyle = .fullScreen + rootVC.present(hostingController, animated: true) } }