Autenticación
Implementa autenticación segura basada en JWT en la aplicación iMirly Android
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:
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:
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
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
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:
@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
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
@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
-
Registrar Nuevo Usuario
Lanza la app, toca "Crear Cuenta", completa el formulario con datos válidos y verifica el mensaje de éxito.
-
Login
Toca "¿Ya tienes cuenta? Iniciar sesión", introduce las credenciales registradas y verifica la navegación a la pantalla principal.
-
Verificar Token
Revisa Logcat para el almacenamiento del token JWT:
TokenManager: JWT saved: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -
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
EncryptedSharedPreferencespara 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
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()
}
}