Harden Android biometric session flow and release process
This commit is contained in:
+7
-1
@@ -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`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
Reference in New Issue
Block a user