diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f09d2..8344869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Versión actual -- Versión base: `1.0.1` +- Versión base: `1.0.2` ## 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 - Se unifica la versión inicial de `frontend`, `backend` y `android`. diff --git a/DEPLOY_ANDROID.md b/DEPLOY_ANDROID.md index 1dc505c..f1df046 100644 --- a/DEPLOY_ANDROID.md +++ b/DEPLOY_ANDROID.md @@ -12,6 +12,7 @@ 2. Si corresponde, sube versión: - `./scripts/set-version.sh X.Y.Z` - actualiza `CHANGELOG.md` + - confirma que `android/app/build.gradle` refleje el nuevo `versionName` y `versionCode` 3. Prueba la app: - `source /etc/profile.d/android-sdk.sh` - `cd android` @@ -40,3 +41,5 @@ - 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. - 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`. diff --git a/DEPLOY_WEB.md b/DEPLOY_WEB.md index 7beaa2c..2686463 100644 --- a/DEPLOY_WEB.md +++ b/DEPLOY_WEB.md @@ -54,3 +54,4 @@ - Si cambias variables de entorno, actualiza `/opt/apps/linkedsign/backend/.env`. - 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/`. +- Si en el despliegue hay cambios Android, recuerda que a producción solo pasa el `APK release` versionado; no el proyecto Android completo. diff --git a/VERSION b/VERSION index 7dea76e..6d7de6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.1 +1.0.2 diff --git a/android/app/build.gradle b/android/app/build.gradle index 41d87c8..ebef117 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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}\"" } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt index 7c19536..cfe2091 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt @@ -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}") } } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt index 7308521..9b58ee3 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt @@ -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? { + fun loadBiometricRefreshToken(): Pair? { 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" } } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt index 67c5236..e98c534 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt @@ -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(null) } - var pendingBiometricPassword by rememberSaveable { mutableStateOf(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") } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt index 2aabe39..eb7197f 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt @@ -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() } diff --git a/backend/package.json b/backend/package.json index 9a0eecd..3d9e23d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "linkedsign-backend", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "Initial backend for LinkedSign", "main": "src/server.js", diff --git a/frontend/package.json b/frontend/package.json index ac6b02f..7bd40b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.1", + "version": "1.0.2", "type": "module", "scripts": { "dev": "vite",