mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Improve authenticateUser flow (#1776)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user