Refactor AuthViewModel to match iOS auth flow
Replace PendingAuth/RSA key passing with tempPassword pattern: - register(): saves keys to disk, stores password temporarily - confirmRegistration(): verifies code, then calls performLogin(email, tempPassword) which re-loads keys from disk - exactly as iOS does - Extract shared performLogin() suspend fn used by both login() and post-confirm flow - Remove PendingAuth data class entirely Simpler, correct, matches reference iOS implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.kecalek.chat.ui.auth
|
package com.kecalek.chat.ui.auth
|
||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.kecalek.chat.core.AuthException
|
import com.kecalek.chat.core.AuthException
|
||||||
@@ -17,16 +18,12 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.security.interfaces.RSAPrivateKey
|
|
||||||
import java.security.interfaces.RSAPublicKey
|
import java.security.interfaces.RSAPublicKey
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
|
||||||
* UI state for all auth screens (Login, Register, Pairing).
|
|
||||||
*/
|
|
||||||
data class AuthUiState(
|
data class AuthUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val loadingMessage: String? = null, // e.g. "Generating keys…", "Connecting…"
|
val loadingMessage: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isLoggedIn: Boolean = false,
|
val isLoggedIn: Boolean = false,
|
||||||
val isRegistered: Boolean = false,
|
val isRegistered: Boolean = false,
|
||||||
@@ -42,19 +39,6 @@ data class AuthUiState(
|
|||||||
val registeredEmail: String? = null,
|
val registeredEmail: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds the already-decrypted key material between register() and confirmRegistration()
|
|
||||||
* so we can auto-login immediately after email confirmation without re-asking for the
|
|
||||||
* password or repeating the expensive PBKDF2 derivation.
|
|
||||||
*
|
|
||||||
* Cleared as soon as it is consumed (or when the ViewModel is cleared).
|
|
||||||
*/
|
|
||||||
private data class PendingAuth(
|
|
||||||
val email: String,
|
|
||||||
val rsaPrivate: RSAPrivateKey,
|
|
||||||
val identityPrivateBytes: ByteArray, // raw Ed25519 seed — used for initLocalKey
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
@@ -64,38 +48,44 @@ class AuthViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(AuthUiState())
|
private val _uiState = MutableStateFlow(AuthUiState())
|
||||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
// Holds key material between register() and confirmRegistration(). Never leaves memory.
|
// Stored temporarily between register() and confirmRegistration() so we can
|
||||||
private var pendingAuth: PendingAuth? = null
|
// auto-login after confirmation without asking the user for their password again.
|
||||||
|
// Cleared immediately after use (or in onCleared).
|
||||||
|
// Matches the iOS pattern: AuthViewModel stores self.password for this purpose.
|
||||||
|
private var tempPassword: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
|
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── LOGIN ─────────────────────────────
|
||||||
|
|
||||||
fun login(emailOrUsername: String, password: String) {
|
fun login(emailOrUsername: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
performLogin(emailOrUsername, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core login logic shared by login() and the auto-login after confirmRegistration().
|
||||||
|
* Matches iOS ChatClient.login() + AuthViewModel.login(appState:).
|
||||||
|
*/
|
||||||
|
private suspend fun performLogin(emailOrUsername: String, password: String) {
|
||||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
|
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
|
||||||
try {
|
try {
|
||||||
if (!keyStorage.hasRsaKeys()) {
|
if (!keyStorage.hasRsaKeys()) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(isLoading = false, error = "No account on this device. Register or pair first.")
|
||||||
isLoading = false,
|
|
||||||
error = "No account on this device. Register or pair first."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return@launch
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load RSA private key (decrypted with user's password via ECP1).
|
// Load RSA private key — PBKDF2 600k iterations, must run off the main thread
|
||||||
// PBKDF2 600k iterations — must run off the main thread.
|
|
||||||
val rsaPrivate = try {
|
val rsaPrivate = try {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) { keyStorage.loadRsaPrivate(password) }
|
||||||
keyStorage.loadRsaPrivate(password)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isLoading = false, error = "Wrong password or corrupted key.") }
|
||||||
it.copy(isLoading = false, error = "Wrong password or corrupted key.")
|
return
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
||||||
@@ -109,7 +99,7 @@ class AuthViewModel @Inject constructor(
|
|||||||
useTls = state.useTls,
|
useTls = state.useTls,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load identity key and init local storage key (also PBKDF2 — off main thread)
|
// Init local DB encryption key from identity key (also PBKDF2, off main thread)
|
||||||
if (keyStorage.hasIdentityKeys()) {
|
if (keyStorage.hasIdentityKeys()) {
|
||||||
val identityPrivate = withContext(Dispatchers.Default) {
|
val identityPrivate = withContext(Dispatchers.Default) {
|
||||||
keyStorage.loadIdentityPrivate(password)
|
keyStorage.loadIdentityPrivate(password)
|
||||||
@@ -118,6 +108,7 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
|
||||||
|
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -126,60 +117,38 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// ───────────────────────────── REGISTER ─────────────────────────────
|
||||||
|
|
||||||
fun register(username: String, email: String, password: String) {
|
fun register(username: String, email: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
|
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
|
||||||
try {
|
try {
|
||||||
// Steps 1-4 are CPU-intensive (RSA-4096 keygen + 2× PBKDF2 600k iters).
|
// CPU-intensive: RSA-4096 keygen + 2× PBKDF2 600k iterations
|
||||||
// Run on Default dispatcher to avoid blocking the UI thread.
|
val (rsaPublicPem, identityKeyBase64) = withContext(Dispatchers.Default) {
|
||||||
data class KeyMaterial(
|
|
||||||
val rsaPublicPem: String,
|
|
||||||
val identityKeyBase64: String,
|
|
||||||
val rsaPrivate: RSAPrivateKey,
|
|
||||||
val identityPrivateBytes: ByteArray,
|
|
||||||
)
|
|
||||||
val keys = withContext(Dispatchers.Default) {
|
|
||||||
// 1. Generate RSA-4096 keypair (~5-30 seconds)
|
|
||||||
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
|
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
|
||||||
|
|
||||||
// 2. Generate Ed25519 identity keypair (fast)
|
|
||||||
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
|
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
|
||||||
|
|
||||||
// 3. Save keys encrypted with password (PBKDF2 600k iters each)
|
|
||||||
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
|
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
|
||||||
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
|
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
|
||||||
|
|
||||||
// 4. Convert to server format and capture private key material
|
Pair(
|
||||||
KeyMaterial(
|
rsaPublicKeyToPem(rsaPublic),
|
||||||
rsaPublicPem = rsaPublicKeyToPem(rsaPublic),
|
Base64.encodeToString(Ed25519Crypto.serializePublic(identityPublic), Base64.NO_WRAP),
|
||||||
identityKeyBase64 = Base64.encodeToString(
|
|
||||||
Ed25519Crypto.serializePublic(identityPublic),
|
|
||||||
Base64.NO_WRAP,
|
|
||||||
),
|
|
||||||
rsaPrivate = rsaPrivate,
|
|
||||||
identityPrivateBytes = Ed25519Crypto.serializePrivate(identityPrivate),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save decrypted keys for use in confirmRegistration (auto-login).
|
// Save password for auto-login after email confirmation (same pattern as iOS)
|
||||||
// pendingAuth is cleared after use or when this ViewModel is destroyed.
|
tempPassword = password
|
||||||
pendingAuth = PendingAuth(
|
|
||||||
email = email,
|
|
||||||
rsaPrivate = keys.rsaPrivate,
|
|
||||||
identityPrivateBytes = keys.identityPrivateBytes,
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") }
|
_uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") }
|
||||||
|
|
||||||
// 5. Register on server (network I/O — SessionManager uses IO dispatcher internally)
|
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
sessionManager.register(
|
sessionManager.register(
|
||||||
username = username,
|
username = username,
|
||||||
email = email,
|
email = email,
|
||||||
rsaPublicKeyPem = keys.rsaPublicPem,
|
rsaPublicKeyPem = rsaPublicPem,
|
||||||
identityKeyBase64 = keys.identityKeyBase64,
|
identityKeyBase64 = identityKeyBase64,
|
||||||
host = state.serverHost,
|
host = state.serverHost,
|
||||||
port = state.serverPort,
|
port = state.serverPort,
|
||||||
useTls = state.useTls,
|
useTls = state.useTls,
|
||||||
@@ -196,10 +165,10 @@ class AuthViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
pendingAuth = null
|
tempPassword = null
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
pendingAuth = null
|
tempPassword = null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}")
|
it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -207,53 +176,36 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── CONFIRM ─────────────────────────────
|
||||||
|
|
||||||
fun confirmRegistration(email: String, code: String) {
|
fun confirmRegistration(email: String, code: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
|
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
|
||||||
try {
|
try {
|
||||||
// Step 1: Verify the email code — this is the only thing that should report
|
// Step 1: Verify the email code — errors here are genuinely "wrong code"
|
||||||
// "wrong code". Any error here is genuinely a bad/expired code.
|
|
||||||
sessionManager.confirmRegistration(email, code)
|
sessionManager.confirmRegistration(email, code)
|
||||||
|
|
||||||
// Step 2: Try auto-login using the key material saved during register().
|
// Step 2: Auto-login exactly as iOS does:
|
||||||
// This is best-effort — failure here does NOT mean the code was wrong.
|
// call login() with the stored password (keys are already on disk from register())
|
||||||
// On failure the user is sent back to LoginScreen to log in manually.
|
val pwd = tempPassword
|
||||||
val auth = pendingAuth
|
tempPassword = null
|
||||||
var loggedIn = false
|
|
||||||
if (auth != null && auth.email == email) {
|
|
||||||
try {
|
|
||||||
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
|
||||||
val state = _uiState.value
|
|
||||||
sessionManager.login(
|
|
||||||
email = email,
|
|
||||||
rsaPrivateKey = auth.rsaPrivate,
|
|
||||||
host = state.serverHost,
|
|
||||||
port = state.serverPort,
|
|
||||||
useTls = state.useTls,
|
|
||||||
)
|
|
||||||
keyStorage.initLocalKey(auth.identityPrivateBytes)
|
|
||||||
loggedIn = true
|
|
||||||
} catch (loginEx: Exception) {
|
|
||||||
// Auto-login failed (e.g. stale connection, transient server error).
|
|
||||||
// Log it but don't surface to user as a "wrong code" error.
|
|
||||||
android.util.Log.w(
|
|
||||||
"AuthViewModel",
|
|
||||||
"Auto-login after confirm failed, user must log in manually: ${loginEx.message}"
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
pendingAuth = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (pwd != null) {
|
||||||
|
// performLogin updates uiState (isLoggedIn = true on success, error on failure)
|
||||||
|
performLogin(email, pwd)
|
||||||
|
} else {
|
||||||
|
// No stored password (e.g. app restarted between register and confirm).
|
||||||
|
// Mark registration done and send to LoginScreen.
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
loadingMessage = null,
|
loadingMessage = null,
|
||||||
isLoggedIn = loggedIn,
|
|
||||||
needsConfirmation = false,
|
needsConfirmation = false,
|
||||||
hasExistingAccount = true,
|
hasExistingAccount = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -264,32 +216,28 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── PAIRING / BIOMETRIC ─────────────────────────────
|
||||||
|
|
||||||
fun startPairing() {
|
fun startPairing() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isLoading = false, error = "Device pairing not yet implemented.") }
|
||||||
it.copy(isLoading = false, error = "Device pairing not yet implemented.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelPairing() {
|
fun cancelPairing() {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false) }
|
||||||
it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loginWithBiometric() {
|
fun loginWithBiometric() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isLoading = false, error = "Biometric login not yet implemented.") }
|
||||||
it.copy(isLoading = false, error = "Biometric login not yet implemented.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── MISC ─────────────────────────────
|
||||||
|
|
||||||
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
|
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(serverHost = host, serverPort = port, useTls = useTls) }
|
||||||
it.copy(serverHost = host, serverPort = port, useTls = useTls)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
@@ -297,16 +245,18 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun resetState() {
|
fun resetState() {
|
||||||
pendingAuth = null
|
tempPassword = null
|
||||||
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
|
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
pendingAuth = null // Ensure key material doesn't linger after ViewModel is destroyed
|
tempPassword = null // don't let the password linger after ViewModel is destroyed
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "AuthViewModel"
|
||||||
|
|
||||||
fun rsaPublicKeyToPem(key: RSAPublicKey): String {
|
fun rsaPublicKeyToPem(key: RSAPublicKey): String {
|
||||||
val der = key.encoded
|
val der = key.encoded
|
||||||
val base64 = Base64.encodeToString(der, Base64.NO_WRAP)
|
val base64 = Base64.encodeToString(der, Base64.NO_WRAP)
|
||||||
|
|||||||
Reference in New Issue
Block a user