Visión General

La aplicación iMirly Android utiliza autenticación JWT (JSON Web Token) con inyección automática de tokens mediante interceptores de OkHttp. El flujo de autenticación incluye:

  • Registro de usuarios con validación
  • Login con email y contraseña
  • Almacenamiento e inyección automática de tokens
  • Identificación de usuarios basada en tokens

Arquitectura de Autenticación

Token Manager

El TokenManager gestiona el almacenamiento y extracción de JWT:

network/TokenManager.kt
package com.imirly.app.network

import android.util.Base64
import org.json.JSONObject

object TokenManager {
    var jwtToken: String? = null

    // Extract user ID from JWT payload
    fun getUserId(): String? {
        val token = jwtToken ?: return null
        return try {
            val parts = token.split(".")
            if (parts.size != 3) return null
            
            // Decode the payload (middle part)
            val payload = String(Base64.decode(parts[1], Base64.URL_SAFE))
            val json = JSONObject(payload)
            json.getString("sub") // Subject contains user ID
        } catch (e: Exception) {
            null
        }
    }
}

Inyección Automática de Tokens

El RetrofitClient añade automáticamente tokens JWT a todas las peticiones:

network/RetrofitClient.kt
package com.imirly.app.network

import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitClient {
    private const val BASE_URL = "http://10.0.2.2:8080"

    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(Interceptor { chain ->
            val originalRequest = chain.request()
            val token = TokenManager.jwtToken

            val newRequest = if (token != null) {
                originalRequest.newBuilder()
                    .header("Authorization", "Bearer $token")
                    .build()
            } else {
                originalRequest
            }

            chain.proceed(newRequest)
        })
        .build()

    val apiService: AuthApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(AuthApiService::class.java)
    }
}

Nota: Todos los servicios de API (catálogo, mensajes, solicitudes) utilizan el mismo okHttpClient con inyección automática de tokens.

Definiciones de Servicios API

Auth API

network/AuthApiService.kt
package com.imirly.app.network

import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import okhttp3.ResponseBody

data class RegisterUserRequest(
    val nombre: String,
    val email: String,
    val rawPassword: String,
    val tipoUsuario: String,
    val documentoIdentidad: String
)

data class LoginUserRequest(
    val email: String,
    val rawPassword: String
)

interface AuthApiService {
    @POST("/api/v1/auth/register")
    suspend fun registerUser(@Body request: RegisterUserRequest): Response

    @POST("/api/v1/auth/login")
    suspend fun loginUser(@Body request: LoginUserRequest): Response
}

Registro de Usuario

Registration ViewModel

ui/auth/RegisterViewModel.kt
package com.imirly.app.ui.auth

import android.util.Patterns
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.RegisterUserRequest
import com.imirly.app.network.RetrofitClient
import kotlinx.coroutines.launch

class RegisterViewModel : ViewModel() {
    var nombre = mutableStateOf("")
    var email = mutableStateOf("")
    var password = mutableStateOf("")
    var confirmPassword = mutableStateOf("")
    var mensaje = mutableStateOf("")
    var isLoading = mutableStateOf(false)

    fun registrarUsuario() {
        // Validation
        if (nombre.value.isBlank() || email.value.isBlank() || password.value.isBlank()) {
            mensaje.value = "Todos los campos son obligatorios"
            return
        }

        if (!Patterns.EMAIL_ADDRESS.matcher(email.value).matches()) {
            mensaje.value = "Formato de e-mail inválido"
            return
        }

        if (password.value != confirmPassword.value) {
            mensaje.value = "Las contraseñas no coinciden"
            return
        }

        if (password.value.length < 6) {
            mensaje.value = "La contraseña debe tener al menos 6 caracteres"
            return
        }

        isLoading.value = true
        viewModelScope.launch {
            try {
                val request = RegisterUserRequest(
                    nombre = nombre.value,
                    email = email.value,
                    rawPassword = password.value,
                    tipoUsuario = "CLIENTE",
                    documentoIdentidad = "00000000A"
                )

                val response = RetrofitClient.apiService.registerUser(request)

                when (response.code()) {
                    201 -> mensaje.value = "¡Cuenta creada con éxito!"
                    409 -> mensaje.value = "Este e-mail ya está registrado"
                    400 -> mensaje.value = "Datos inválidos. Revisa el formulario"
                    else -> mensaje.value = "Error del servidor: ${response.code()}"
                }
            } catch (e: Exception) {
                mensaje.value = "Error de red: Comprueba tu conexión"
            } finally {
                isLoading.value = false
            }
        }
    }
}

Registration Screen

La UI recoge la información del usuario con componentes Material 3:

ui/auth/RegisterScreen.kt
@Composable
fun RegisterScreen(navController: NavController, viewModel: RegisterViewModel = viewModel()) {
    val iMirlyPurple = Color(0xFF6C5CE7)
    
    Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
        Text("Crear cuenta", fontSize = 28.sp, fontWeight = FontWeight.Bold)
        
        OutlinedTextField(
            value = viewModel.nombre.value,
            onValueChange = { viewModel.nombre.value = it },
            label = { Text("Nombre completo") },
            modifier = Modifier.fillMaxWidth()
        )
        
        OutlinedTextField(
            value = viewModel.email.value,
            onValueChange = { viewModel.email.value = it },
            label = { Text("Email") },
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
            modifier = Modifier.fillMaxWidth()
        )
        
        // Password fields...
        
        Button(
            onClick = { viewModel.registrarUsuario() },
            enabled = !viewModel.isLoading.value,
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(if (viewModel.isLoading.value) "Creando cuenta..." else "Registrarse")
        }
        
        if (viewModel.mensaje.value.isNotEmpty()) {
            Text(
                text = viewModel.mensaje.value,
                color = if (viewModel.mensaje.value.contains("éxito")) Color.Green else Color.Red
            )
        }
    }
}

Login de Usuario

Login ViewModel

ui/auth/LoginViewModel.kt
package com.imirly.app.ui.auth

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.LoginUserRequest
import com.imirly.app.network.RetrofitClient
import com.imirly.app.network.TokenManager
import kotlinx.coroutines.launch

class LoginViewModel : ViewModel() {
    var email = mutableStateOf("")
    var password = mutableStateOf("")
    var mensaje = mutableStateOf("")
    var isLoading = mutableStateOf(false)

    fun iniciarSesion(onSuccess: () -> Unit) {
        if (email.value.isEmpty() || password.value.isEmpty()) {
            mensaje.value = "Introduce tus credenciales"
            return
        }
        
        isLoading.value = true
        viewModelScope.launch {
            try {
                val request = LoginUserRequest(email.value, password.value)
                val response = RetrofitClient.apiService.loginUser(request)
                
                if (response.isSuccessful) {
                    // Extract JWT token from response
                    val token = response.body()?.string()
                    if (token != null) {
                        TokenManager.jwtToken = token
                        mensaje.value = "¡Sesión iniciada con éxito!"
                        onSuccess()
                    } else {
                        mensaje.value = "Error: Token vacío"
                    }
                } else {
                    mensaje.value = "Email o contraseña incorrectos"
                }
            } catch (e: Exception) {
                mensaje.value = "Error de conexión con el servidor"
            } finally {
                isLoading.value = false
            }
        }
    }
}

Login Screen

ui/auth/LoginScreen.kt
@Composable
fun LoginScreen(navController: NavController, viewModel: LoginViewModel = viewModel()) {
    val iMirlyPurple = Color(0xFF6C5CE7)
    
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(iMirlyPurple)
            .padding(24.dp),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                "iMirly",
                color = Color.White,
                fontSize = 42.sp,
                fontWeight = FontWeight.ExtraBold
            )
            
            Spacer(modifier = Modifier.height(32.dp))
            
            Surface(
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(28.dp),
                color = Color.White
            ) {
                Column(
                    modifier = Modifier.padding(24.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text("Iniciar sesión", fontSize = 20.sp, fontWeight = FontWeight.Bold)
                    
                    Spacer(modifier = Modifier.height(24.dp))
                    
                    OutlinedTextField(
                        value = viewModel.email.value,
                        onValueChange = { viewModel.email.value = it },
                        label = { Text("Email") },
                        leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
                        modifier = Modifier.fillMaxWidth()
                    )
                    
                    OutlinedTextField(
                        value = viewModel.password.value,
                        onValueChange = { viewModel.password.value = it },
                        label = { Text("Contraseña") },
                        leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
                        visualTransformation = PasswordVisualTransformation(),
                        modifier = Modifier.fillMaxWidth()
                    )

                    if (viewModel.mensaje.value.isNotEmpty()) {
                        Text(
                            text = viewModel.mensaje.value,
                            color = if (viewModel.mensaje.value.contains("éxito")) 
                                Color(0xFF2ECC71) else Color.Red,
                            fontSize = 13.sp,
                            fontWeight = FontWeight.Medium,
                            modifier = Modifier.padding(top = 10.dp)
                        )
                    }
                    
                    Spacer(modifier = Modifier.height(24.dp))
                    
                    Button(
                        onClick = {
                            viewModel.iniciarSesion {
                                navController.navigate("main") {
                                    popUpTo("login") { inclusive = true }
                                }
                            }
                        },
                        modifier = Modifier.fillMaxWidth().height(50.dp),
                        shape = RoundedCornerShape(12.dp),
                        colors = ButtonDefaults.buttonColors(containerColor = iMirlyPurple)
                    ) {
                        Text(if (viewModel.isLoading.value) "Cargando..." else "Iniciar sesión")
                    }
                    
                    Spacer(modifier = Modifier.height(16.dp))
                    
                    Text(
                        "¿No tienes cuenta? Crear cuenta",
                        color = iMirlyPurple,
                        fontWeight = FontWeight.SemiBold,
                        modifier = Modifier.clickable { navController.navigate("register") }
                    )
                }
            }
        }
    }
}

Probar Autenticación

  1. Registrar Nuevo Usuario

    Lanza la app, toca "Crear Cuenta", completa el formulario con datos válidos y verifica el mensaje de éxito.

  2. Login

    Toca "¿Ya tienes cuenta? Iniciar sesión", introduce las credenciales registradas y verifica la navegación a la pantalla principal.

  3. Verificar Token

    Revisa Logcat para el almacenamiento del token JWT:

    TokenManager: JWT saved: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  4. Probar Endpoints Protegidos

    Navega a cualquier pantalla protegida (Perfil, Mensajes) y verifica que no hay errores 401/403.

Mejores Prácticas de Seguridad

La implementación actual almacena tokens en memoria. Para apps en producción:

  • Usa EncryptedSharedPreferences para almacenamiento persistente
  • Implementa lógica de refresh de tokens
  • Maneja la expiración de tokens gracefully
  • Limpia tokens al hacer logout

Ejemplo de Almacenamiento Encriptado

SecureTokenManager.kt
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureTokenManager(context: Context) {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val sharedPreferences = EncryptedSharedPreferences.create(
        context,
        "secret_shared_prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveToken(token: String) {
        sharedPreferences.edit().putString("jwt_token", token).apply()
    }

    fun getToken(): String? {
        return sharedPreferences.getString("jwt_token", null)
    }

    fun clearToken() {
        sharedPreferences.edit().remove("jwt_token").apply()
    }
}

Próximos Pasos