Harden Android biometric session flow and release process

This commit is contained in:
2026-04-07 21:29:37 -05:00
parent 15e8d55fca
commit 89b09cd8ac
11 changed files with 62 additions and 44 deletions
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign"
minSdk 26
targetSdk 35
versionCode 10001
versionName "1.0.1"
versionCode 10002
versionName "1.0.2"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
}
@@ -75,6 +75,7 @@ class LinkedSignRepository(private val context: Context) {
)
sessionStore.save(session)
localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(response.user.email, response.refreshToken)
session
}
}
@@ -95,8 +96,9 @@ class LinkedSignRepository(private val context: Context) {
suspend fun logout() {
execute {
val session = sessionStore.load()
val biometric = sessionStore.loadBiometricState()
try {
if (!session.refreshToken.isNullOrBlank()) {
if (!session.refreshToken.isNullOrBlank() && !biometric.enabled) {
unwrap(service.logout(RefreshRequest(session.refreshToken)))
}
} finally {
@@ -106,23 +108,41 @@ class LinkedSignRepository(private val context: Context) {
}
suspend fun biometricLogin(): SessionSnapshot {
val credentials = sessionStore.loadBiometricCredentials()
?: throw AppException("No hay credenciales biométricas guardadas.")
return login(credentials.first, credentials.second)
val biometric = sessionStore.loadBiometricRefreshToken()
?: throw AppException("No hay sesión biométrica guardada.")
return execute {
val refresh = unwrap(service.refresh(RefreshRequest(biometric.second)))
val user = unwrap(service.me("Bearer ${refresh.accessToken}")).user
val session = SessionSnapshot(
accessToken = refresh.accessToken,
refreshToken = refresh.refreshToken,
user = user
)
sessionStore.save(session)
localCache.saveProfile(user)
sessionStore.updateBiometricSession(user.email, refresh.refreshToken)
session
}
}
fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState()
fun enableBiometricAccess(email: String, password: String) {
sessionStore.saveBiometricAccess(email.trim(), password)
fun enableBiometricAccess() {
val session = sessionStore.load()
val user = session.user ?: throw AppException("Primero debes iniciar sesión.")
val refreshToken = session.refreshToken ?: throw AppException("No hay sesión para activar biometría.")
sessionStore.saveBiometricAccess(user.email, refreshToken)
}
fun declineBiometricAccess() {
sessionStore.declineBiometricAccess()
}
fun syncBiometricCredentials(email: String, password: String) {
sessionStore.updateBiometricCredentials(email.trim(), password)
fun syncBiometricSession() {
val session = sessionStore.load()
val user = session.user ?: return
val refreshToken = session.refreshToken ?: return
sessionStore.updateBiometricSession(user.email, refreshToken)
}
suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
@@ -201,6 +221,7 @@ class LinkedSignRepository(private val context: Context) {
refreshToken = refresh.refreshToken
)
sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
block("Bearer ${refresh.accessToken}")
}
}
@@ -54,22 +54,22 @@ class SessionStore(context: Context) {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
val email = preferences.getString(KEY_BIO_EMAIL, null)
val password = preferences.getString(KEY_BIO_PASSWORD, null)
val refreshToken = preferences.getString(KEY_BIO_REFRESH, null)
return BiometricAccessState(
enabled = enabled,
configured = configured,
email = email,
hasCredentials = !email.isNullOrBlank() && !password.isNullOrBlank()
hasCredentials = !email.isNullOrBlank() && !refreshToken.isNullOrBlank()
)
}
fun saveBiometricAccess(email: String, password: String) {
fun saveBiometricAccess(email: String, refreshToken: String) {
preferences.edit()
.putBoolean(KEY_BIO_ENABLED, true)
.putBoolean(KEY_BIO_CONFIGURED, true)
.putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_PASSWORD, password)
.putString(KEY_BIO_REFRESH, refreshToken)
.apply()
}
@@ -78,28 +78,28 @@ class SessionStore(context: Context) {
.putBoolean(KEY_BIO_ENABLED, false)
.putBoolean(KEY_BIO_CONFIGURED, true)
.remove(KEY_BIO_EMAIL)
.remove(KEY_BIO_PASSWORD)
.remove(KEY_BIO_REFRESH)
.apply()
}
fun updateBiometricCredentials(email: String, password: String) {
fun updateBiometricSession(email: String, refreshToken: String) {
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
return
}
preferences.edit()
.putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_PASSWORD, password)
.putString(KEY_BIO_REFRESH, refreshToken)
.apply()
}
fun loadBiometricCredentials(): Pair<String, String>? {
fun loadBiometricRefreshToken(): Pair<String, String>? {
val email = preferences.getString(KEY_BIO_EMAIL, null)
val password = preferences.getString(KEY_BIO_PASSWORD, null)
if (email.isNullOrBlank() || password.isNullOrBlank()) {
val refreshToken = preferences.getString(KEY_BIO_REFRESH, null)
if (email.isNullOrBlank() || refreshToken.isNullOrBlank()) {
return null
}
return email to password
return email to refreshToken
}
companion object {
@@ -109,6 +109,6 @@ class SessionStore(context: Context) {
private const val KEY_BIO_ENABLED = "bio_enabled"
private const val KEY_BIO_CONFIGURED = "bio_configured"
private const val KEY_BIO_EMAIL = "bio_email"
private const val KEY_BIO_PASSWORD = "bio_password"
private const val KEY_BIO_REFRESH = "bio_refresh_token"
}
}
@@ -205,8 +205,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
val biometricAvailable = remember(activity) { canUseBiometricAccess(activity) }
var appUnlocked by rememberSaveable { mutableStateOf(false) }
var bootstrapGateHandled by rememberSaveable { mutableStateOf(false) }
var pendingBiometricEmail by rememberSaveable { mutableStateOf<String?>(null) }
var pendingBiometricPassword by rememberSaveable { mutableStateOf<String?>(null) }
var showBiometricOptIn by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -354,8 +352,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
.onSuccess {
selectedTab = AppTab.Documents
appUnlocked = true
pendingBiometricEmail = email.trim()
pendingBiometricPassword = password
showBiometricOptIn = biometricAvailable && !state.biometricConfigured
snackbarHostState.showSnackbar("Sesión iniciada.")
}
@@ -434,13 +430,11 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}
}
if (showBiometricOptIn && pendingBiometricEmail != null && pendingBiometricPassword != null) {
if (showBiometricOptIn) {
AlertDialog(
onDismissRequest = {
viewModel.declineBiometricAccess()
showBiometricOptIn = false
pendingBiometricEmail = null
pendingBiometricPassword = null
},
title = { Text("Activar acceso biométrico") },
text = {
@@ -448,13 +442,8 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
},
confirmButton = {
Button(onClick = {
viewModel.enableBiometricAccess(
pendingBiometricEmail.orEmpty(),
pendingBiometricPassword.orEmpty()
)
viewModel.enableBiometricAccess()
showBiometricOptIn = false
pendingBiometricEmail = null
pendingBiometricPassword = null
}) {
Text("Sí, usar huella")
}
@@ -463,8 +452,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
OutlinedButton(onClick = {
viewModel.declineBiometricAccess()
showBiometricOptIn = false
pendingBiometricEmail = null
pendingBiometricPassword = null
}) {
Text("Ahora no")
}
@@ -52,7 +52,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun login(email: String, password: String) {
val session = repository.login(email, password)
repository.syncBiometricCredentials(email, password)
repository.syncBiometricSession()
val (_, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false)
}
@@ -145,8 +145,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun accessToken(): String? = repository.getAccessToken()
fun enableBiometricAccess(email: String, password: String) {
repository.enableBiometricAccess(email, password)
fun enableBiometricAccess() {
repository.enableBiometricAccess()
refreshState()
}