Improve authenticateUser flow (#1776)

This commit is contained in:
Leendert de Borst
2026-02-24 21:33:00 +01:00
parent 042788cc38
commit cb74b0571a
4 changed files with 228 additions and 45 deletions

View File

@@ -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)
}
}
}

View File

@@ -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<String?, String?>? = 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)

View File

@@ -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')

View File

@@ -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)
}
}