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
+7 -1
View File
@@ -2,10 +2,16 @@
## Versión actual ## Versión actual
- Versión base: `1.0.1` - Versión base: `1.0.2`
## Historial ## Historial
### 1.0.2 - 2026-04-08
- Se endurece el flujo biométrico Android para usar sesión por token en lugar de guardar la contraseña del usuario.
- Se actualizan las guías de despliegue para dejar explícito que en producción solo se publica el `APK release`.
- Se refuerza la regla operativa de mantener producción en estado `hardened`.
### 1.0.1 - 2026-04-07 ### 1.0.1 - 2026-04-07
- Se unifica la versión inicial de `frontend`, `backend` y `android`. - Se unifica la versión inicial de `frontend`, `backend` y `android`.
+3
View File
@@ -12,6 +12,7 @@
2. Si corresponde, sube versión: 2. Si corresponde, sube versión:
- `./scripts/set-version.sh X.Y.Z` - `./scripts/set-version.sh X.Y.Z`
- actualiza `CHANGELOG.md` - actualiza `CHANGELOG.md`
- confirma que `android/app/build.gradle` refleje el nuevo `versionName` y `versionCode`
3. Prueba la app: 3. Prueba la app:
- `source /etc/profile.d/android-sdk.sh` - `source /etc/profile.d/android-sdk.sh`
- `cd android` - `cd android`
@@ -40,3 +41,5 @@
- Si cambias algo visual de la app, no hace falta tocar la web. - Si cambias algo visual de la app, no hace falta tocar la web.
- Si cambias el contrato del API, prueba login, documentos y firma antes de subir release. - Si cambias el contrato del API, prueba login, documentos y firma antes de subir release.
- En producción no debe quedar `android/` fuente; solo `/opt/apps/linkedsign/android/apk/LinkedSign-release.apk`. - En producción no debe quedar `android/` fuente; solo `/opt/apps/linkedsign/android/apk/LinkedSign-release.apk`.
- En cada paso a producción Android se publica únicamente el `APK release`; no se sube el código fuente Android.
- El `APK release` que se publique debe corresponder a una versión nueva y anotada en `VERSION` y `CHANGELOG.md`.
+1
View File
@@ -54,3 +54,4 @@
- Si cambias variables de entorno, actualiza `/opt/apps/linkedsign/backend/.env`. - Si cambias variables de entorno, actualiza `/opt/apps/linkedsign/backend/.env`.
- Si cambias rutas del APK o storage, revisa también `PRODUCCION.md`. - Si cambias rutas del APK o storage, revisa también `PRODUCCION.md`.
- En producción no debe quedar código fuente Android; solo el APK release en `/opt/apps/linkedsign/android/apk/`. - En producción no debe quedar código fuente Android; solo el APK release en `/opt/apps/linkedsign/android/apk/`.
- Si en el despliegue hay cambios Android, recuerda que a producción solo pasa el `APK release` versionado; no el proyecto Android completo.
+1 -1
View File
@@ -1 +1 @@
1.0.1 1.0.2
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign" applicationId "net.linkedcorp.linkedsign"
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 10001 versionCode 10002
versionName "1.0.1" versionName "1.0.2"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
} }
@@ -75,6 +75,7 @@ class LinkedSignRepository(private val context: Context) {
) )
sessionStore.save(session) sessionStore.save(session)
localCache.saveProfile(response.user) localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(response.user.email, response.refreshToken)
session session
} }
} }
@@ -95,8 +96,9 @@ class LinkedSignRepository(private val context: Context) {
suspend fun logout() { suspend fun logout() {
execute { execute {
val session = sessionStore.load() val session = sessionStore.load()
val biometric = sessionStore.loadBiometricState()
try { try {
if (!session.refreshToken.isNullOrBlank()) { if (!session.refreshToken.isNullOrBlank() && !biometric.enabled) {
unwrap(service.logout(RefreshRequest(session.refreshToken))) unwrap(service.logout(RefreshRequest(session.refreshToken)))
} }
} finally { } finally {
@@ -106,23 +108,41 @@ class LinkedSignRepository(private val context: Context) {
} }
suspend fun biometricLogin(): SessionSnapshot { suspend fun biometricLogin(): SessionSnapshot {
val credentials = sessionStore.loadBiometricCredentials() val biometric = sessionStore.loadBiometricRefreshToken()
?: throw AppException("No hay credenciales biométricas guardadas.") ?: throw AppException("No hay sesión biométrica guardada.")
return login(credentials.first, credentials.second) 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 biometricState(): BiometricAccessState = sessionStore.loadBiometricState()
fun enableBiometricAccess(email: String, password: String) { fun enableBiometricAccess() {
sessionStore.saveBiometricAccess(email.trim(), password) 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() { fun declineBiometricAccess() {
sessionStore.declineBiometricAccess() sessionStore.declineBiometricAccess()
} }
fun syncBiometricCredentials(email: String, password: String) { fun syncBiometricSession() {
sessionStore.updateBiometricCredentials(email.trim(), password) 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 = suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
@@ -201,6 +221,7 @@ class LinkedSignRepository(private val context: Context) {
refreshToken = refresh.refreshToken refreshToken = refresh.refreshToken
) )
sessionStore.save(updated) sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
block("Bearer ${refresh.accessToken}") block("Bearer ${refresh.accessToken}")
} }
} }
@@ -54,22 +54,22 @@ class SessionStore(context: Context) {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false) val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false) val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
val email = preferences.getString(KEY_BIO_EMAIL, null) 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( return BiometricAccessState(
enabled = enabled, enabled = enabled,
configured = configured, configured = configured,
email = email, 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() preferences.edit()
.putBoolean(KEY_BIO_ENABLED, true) .putBoolean(KEY_BIO_ENABLED, true)
.putBoolean(KEY_BIO_CONFIGURED, true) .putBoolean(KEY_BIO_CONFIGURED, true)
.putString(KEY_BIO_EMAIL, email) .putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_PASSWORD, password) .putString(KEY_BIO_REFRESH, refreshToken)
.apply() .apply()
} }
@@ -78,28 +78,28 @@ class SessionStore(context: Context) {
.putBoolean(KEY_BIO_ENABLED, false) .putBoolean(KEY_BIO_ENABLED, false)
.putBoolean(KEY_BIO_CONFIGURED, true) .putBoolean(KEY_BIO_CONFIGURED, true)
.remove(KEY_BIO_EMAIL) .remove(KEY_BIO_EMAIL)
.remove(KEY_BIO_PASSWORD) .remove(KEY_BIO_REFRESH)
.apply() .apply()
} }
fun updateBiometricCredentials(email: String, password: String) { fun updateBiometricSession(email: String, refreshToken: String) {
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) { if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
return return
} }
preferences.edit() preferences.edit()
.putString(KEY_BIO_EMAIL, email) .putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_PASSWORD, password) .putString(KEY_BIO_REFRESH, refreshToken)
.apply() .apply()
} }
fun loadBiometricCredentials(): Pair<String, String>? { fun loadBiometricRefreshToken(): Pair<String, String>? {
val email = preferences.getString(KEY_BIO_EMAIL, null) val email = preferences.getString(KEY_BIO_EMAIL, null)
val password = preferences.getString(KEY_BIO_PASSWORD, null) val refreshToken = preferences.getString(KEY_BIO_REFRESH, null)
if (email.isNullOrBlank() || password.isNullOrBlank()) { if (email.isNullOrBlank() || refreshToken.isNullOrBlank()) {
return null return null
} }
return email to password return email to refreshToken
} }
companion object { companion object {
@@ -109,6 +109,6 @@ class SessionStore(context: Context) {
private const val KEY_BIO_ENABLED = "bio_enabled" private const val KEY_BIO_ENABLED = "bio_enabled"
private const val KEY_BIO_CONFIGURED = "bio_configured" private const val KEY_BIO_CONFIGURED = "bio_configured"
private const val KEY_BIO_EMAIL = "bio_email" 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) } val biometricAvailable = remember(activity) { canUseBiometricAccess(activity) }
var appUnlocked by rememberSaveable { mutableStateOf(false) } var appUnlocked by rememberSaveable { mutableStateOf(false) }
var bootstrapGateHandled 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) } var showBiometricOptIn by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -354,8 +352,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
.onSuccess { .onSuccess {
selectedTab = AppTab.Documents selectedTab = AppTab.Documents
appUnlocked = true appUnlocked = true
pendingBiometricEmail = email.trim()
pendingBiometricPassword = password
showBiometricOptIn = biometricAvailable && !state.biometricConfigured showBiometricOptIn = biometricAvailable && !state.biometricConfigured
snackbarHostState.showSnackbar("Sesión iniciada.") 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( AlertDialog(
onDismissRequest = { onDismissRequest = {
viewModel.declineBiometricAccess() viewModel.declineBiometricAccess()
showBiometricOptIn = false showBiometricOptIn = false
pendingBiometricEmail = null
pendingBiometricPassword = null
}, },
title = { Text("Activar acceso biométrico") }, title = { Text("Activar acceso biométrico") },
text = { text = {
@@ -448,13 +442,8 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}, },
confirmButton = { confirmButton = {
Button(onClick = { Button(onClick = {
viewModel.enableBiometricAccess( viewModel.enableBiometricAccess()
pendingBiometricEmail.orEmpty(),
pendingBiometricPassword.orEmpty()
)
showBiometricOptIn = false showBiometricOptIn = false
pendingBiometricEmail = null
pendingBiometricPassword = null
}) { }) {
Text("Sí, usar huella") Text("Sí, usar huella")
} }
@@ -463,8 +452,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
OutlinedButton(onClick = { OutlinedButton(onClick = {
viewModel.declineBiometricAccess() viewModel.declineBiometricAccess()
showBiometricOptIn = false showBiometricOptIn = false
pendingBiometricEmail = null
pendingBiometricPassword = null
}) { }) {
Text("Ahora no") Text("Ahora no")
} }
@@ -52,7 +52,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun login(email: String, password: String) { suspend fun login(email: String, password: String) {
val session = repository.login(email, password) val session = repository.login(email, password)
repository.syncBiometricCredentials(email, password) repository.syncBiometricSession()
val (_, cache) = repository.loadLocalSnapshot() val (_, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false) uiState = buildState(session, cache, loading = false)
} }
@@ -145,8 +145,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun accessToken(): String? = repository.getAccessToken() fun accessToken(): String? = repository.getAccessToken()
fun enableBiometricAccess(email: String, password: String) { fun enableBiometricAccess() {
repository.enableBiometricAccess(email, password) repository.enableBiometricAccess()
refreshState() refreshState()
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkedsign-backend", "name": "linkedsign-backend",
"version": "1.0.1", "version": "1.0.2",
"private": true, "private": true,
"description": "Initial backend for LinkedSign", "description": "Initial backend for LinkedSign",
"main": "src/server.js", "main": "src/server.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.1", "version": "1.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",