Almacenamiento seguro de tokens y cierre de sesión en iOS

En el artículo anterior implementamos el inicio de sesión con AWS Cognito y pudimos ver los tokens que nos devuelve (Access Token, ID Token, Refresh Token). Sin embargo, estos tokens se pierden cuando cierras la app. En este artículo vamos a implementar el almacenamiento seguro de tokens usando Keychain, crear una pantalla de bienvenida, y agregar la funcionalidad de cerrar sesión.

¿Qué vamos a construir?

En este artículo implementaremos:

  1. KeychainManager – Clase para guardar y leer datos de forma segura en Keychain
  2. Guardar tokens – Después del login, guardar los 3 tokens en Keychain
  3. HomeView – Pantalla de bienvenida que se muestra después del login exitoso
  4. Cerrar sesión – Botón que elimina los tokens y regresa al usuario a la pantalla de login

¿Por qué Keychain?

En iOS hay varias formas de guardar datos:

OpciónSeguridad¿Para tokens?
UserDefaults❌ Sin cifrado❌ NUNCA
Archivos❌ Sin cifrado❌ NUNCA
Core Data⚠️ Cifrado opcional⚠️ No recomendado
Keychain✅ Cifrado por hardware✅ SÍ

Keychain es la única opción segura para tokens porque: – Los datos están cifrados por el Secure Enclave (chip de seguridad) – El sistema operativo gestiona el cifrado automáticamente – Los datos persisten entre instalaciones de la app (opcional) – Es el estándar de Apple para credenciales sensibles

NUNCA guardes tokens en: – UserDefaults – cualquiera con acceso al dispositivo puede leerlos – Archivos sin cifrar – igual que UserDefaults – Variables globales – se pierden al cerrar la app

Requisitos previos

  • Haber completado el Artículo 3 (Inicio de sesión)
  • Tener un usuario que pueda hacer login
  • Entender qué son los tokens de AWS Cognito

Paso 1: Crear KeychainManager

Vamos a crear una clase que nos permita guardar y leer datos del Keychain de forma sencilla.

1.1 Crear el archivo

En Xcode: 1. Click derecho en la carpeta CognitoAuthDemo 2. New File → Swift File 3. Nombre: KeychainManager

1.2 Código completo del KeychainManager

import Foundation
import Security

class KeychainManager {

    static let shared = KeychainManager()

    private init() {}

    //  Guardar en Keychain

    /// Guarda un valor String en el Keychain
    @discardableResult
    func save(key: String, value: String) -> Bool {
        // Convertir el string a Data
        guard let data = value.data(using: .utf8) else {
            print("❌ Error: No se pudo convertir el valor a Data")
            return false
        }

        // Eliminar cualquier valor previo con esta key
        delete(key: key)

        // Crear query para guardar en Keychain
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]

        // Intentar guardar
        let status = SecItemAdd(query as CFDictionary, nil)

        if status == errSecSuccess {
            print("✅ Guardado en Keychain: \(key)")
            return true
        } else {
            print("❌ Error al guardar en Keychain: \(status)")
            return false
        }
    }

    // Leer del Keychain

    /// Lee un valor String del Keychain
    func read(key: String) -> String? {
        // Crear query para buscar en Keychain
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        if status == errSecSuccess,
           let data = result as? Data,
           let value = String(data: data, encoding: .utf8) {
            print("✅ Leído del Keychain: \(key)")
            return value
        } else {
            print("⚠️ No se encontró en Keychain: \(key)")
            return nil
        }
    }

    // Eliminar del Keychain

    /// Elimina un valor del Keychain
    @discardableResult
    func delete(key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]

        let status = SecItemDelete(query as CFDictionary)

        if status == errSecSuccess {
            print("✅ Eliminado del Keychain: \(key)")
            return true
        } else if status == errSecItemNotFound {
            return true
        } else {
            print("❌ Error al eliminar del Keychain: \(status)")
            return false
        }
    }

    // Eliminar todos los datos

    /// Elimina TODOS los datos guardados por esta app en Keychain
    @discardableResult
    func deleteAll() -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword
        ]

        let status = SecItemDelete(query as CFDictionary)

        if status == errSecSuccess || status == errSecItemNotFound {
            print("✅ Keychain limpiado completamente")
            return true
        } else {
            print("❌ Error al limpiar Keychain: \(status)")
            return false
        }
    }
}

// Keys para los tokens

extension KeychainManager {

    enum TokenKey {
        static let accessToken = "cognito.accessToken"
        static let idToken = "cognito.idToken"
        static let refreshToken = "cognito.refreshToken"
    }

    // Métodos convenientes para tokens

    /// Guarda los tres tokens de Cognito en Keychain
    func saveTokens(_ tokens: AuthTokens) -> Bool {
        let accessSaved = save(key: TokenKey.accessToken, value: tokens.accessToken)
        let idSaved = save(key: TokenKey.idToken, value: tokens.idToken)
        let refreshSaved = save(key: TokenKey.refreshToken, value: tokens.refreshToken)

        return accessSaved && idSaved && refreshSaved
    }

    /// Lee los tres tokens de Cognito del Keychain
    func readTokens() -> AuthTokens? {
        guard let accessToken = read(key: TokenKey.accessToken),
              let idToken = read(key: TokenKey.idToken),
              let refreshToken = read(key: TokenKey.refreshToken) else {
            print("⚠️ No se encontraron tokens guardados")
            return nil
        }

        return AuthTokens(
            accessToken: accessToken,
            idToken: idToken,
            refreshToken: refreshToken
        )
    }

    /// Elimina todos los tokens de Cognito del Keychain
    func deleteTokens() -> Bool {
        let accessDeleted = delete(key: TokenKey.accessToken)
        let idDeleted = delete(key: TokenKey.idToken)
        let refreshDeleted = delete(key: TokenKey.refreshToken)

        return accessDeleted && idDeleted && refreshDeleted
    }

    /// Verifica si hay tokens guardados
    func hasTokens() -> Bool {
        return read(key: TokenKey.accessToken) != nil
    }
}

1.3 ¿Cómo funciona?

Patrón Singleton:

static let shared = KeychainManager()
private init() {}
  • Solo existe una instancia de KeychainManager en toda la app
  • Se accede con KeychainManager.shared
  • Evita crear múltiples instancias que accedan al Keychain

Métodos básicos:

  • save(key:value:) – Guardar datos
    • Convierte el String a Data
    • Elimina cualquier valor previo con la misma key (evita duplicados)
    • Crea un “query” con los parámetros necesarios
    • Usa SecItemAdd para guardar en Keychain
    • Retorna true si fue exitoso
  • read(key:) – Leer datos
    • Crea un query para buscar
    • Usa SecItemCopyMatching para leerConvierte Data de vuelta a String
    • Retorna nil si no existe
  • delete(key:) – Eliminar un dato
    • Usa SecItemDelete para eliminar
    • Retorna true si fue exitoso (o si no existía)
  • deleteAll() – Eliminar todos los datos
    • Elimina TODOS los datos de esta app en Keychain
    • Usar con cuidado

Parámetros importantes del query:

  • kSecClass: Tipo de dato (usamos kSecClassGenericPassword para tokens)
  • kSecAttrAccount: La “key” que identifica el dato
  • kSecValueData: El valor a guardar (como Data)
  • kSecAttrAccessible: Cuándo es accesible el dato

kSecAttrAccessibleWhenUnlockedThisDeviceOnly: – Los datos solo son accesibles cuando el dispositivo está desbloqueado – NO se sincronizan con iCloud – NO se transfieren a otros dispositivos – La opción más segura para tokens

Extension para tokens:

Para facilitar el trabajo con tokens de Cognito, agregamos métodos específicos:

  • saveTokens(_:) – Guarda los 3 tokens a la vez
  • readTokens() – Lee los 3 tokens y retorna AuthTokens?
  • deleteTokens() – Elimina los 3 tokens
  • hasTokens() – Verifica si hay tokens (útil para saber si hay sesión)

Keys de los tokens:

enum TokenKey {
    static let accessToken = "cognito.accessToken"
    static let idToken = "cognito.idToken"
    static let refreshToken = "cognito.refreshToken"
}
  • Usamos un enum para evitar typos
  • Prefijo “cognito.” para identificar que son de Cognito
  • Nombres claros y descriptivos

Paso 2: Crear HomeView (pantalla de bienvenida)

Ahora vamos a crear una pantalla simple que se mostrará después del login exitoso.

2.1 Crear el archivo

En Xcode: 1. Click derecho en la carpeta CognitoAuthDemo 2. New File → SwiftUI View 3. Nombre: HomeView

2.2 Código de HomeView

import SwiftUI

struct HomeView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var showingSignOut = false

    var body: some View {
        VStack(spacing: 30) {
            Spacer()

            // Icono de bienvenida
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 80))
                .foregroundColor(.green)

            // Mensaje de bienvenida
            Text("¡Bienvenido!")
                .font(.largeTitle)
                .fontWeight(.bold)

            Text("Has iniciado sesión exitosamente")
                .font(.body)
                .foregroundColor(.gray)

            Text("Tus tokens están guardados de forma segura en Keychain")
                .font(.caption)
                .foregroundColor(.gray)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 40)

            Spacer()

            // Botón de cerrar sesión
            Button(action: {
                showingSignOut = true
            }) {
                Label("Cerrar Sesión", systemImage: "arrow.right.square")
                    .fontWeight(.semibold)
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.red)
                    .cornerRadius(10)
            }
            .padding(.horizontal, 30)
            .padding(.bottom, 50)
        }
        .navigationBarBackButtonHidden(true)
        .alert("Cerrar Sesión", isPresented: $showingSignOut) {
            Button("Cancelar", role: .cancel) { }
            Button("Cerrar Sesión", role: .destructive) {
                signOut()
            }
        } message: {
            Text("¿Estás seguro que deseas cerrar sesión?")
        }
    }

    //Cerrar Sesión
    private func signOut() {
        // Eliminar tokens del Keychain
        let success = KeychainManager.shared.deleteTokens()

        if success {
            print("✅ Tokens eliminados. Sesión cerrada.")
            // Volver a SignInView
            dismiss()
        } else {
            print("❌ Error al eliminar tokens")
        }
    }
}

2.3 ¿Qué hace este código?

@Environment(.dismiss): – Permite cerrar la vista actual y volver atrás en el stack de navegación – Cuando el usuario cierra sesión, usamos dismiss() para volver a SignInView

navigationBarBackButtonHidden(true): – Oculta el botón de “volver” de la navegación – El usuario NO puede regresar a HomeView sin hacer login otra vez – Solo puede salir usando “Cerrar Sesión”

Alert de confirmación: – Pregunta “¿Estás seguro que deseas cerrar sesión?” – Dos opciones: Cancelar o Cerrar Sesión – Evita que el usuario cierre sesión por accidente

Función signOut(): 1. Llama a KeychainManager.shared.deleteTokens() 2. Elimina los 3 tokens del Keychain 3. Usa dismiss() para volver a SignInView

Paso 3: Actualizar SignInView para guardar tokens y navegar a HomeView

Ahora vamos a modificar SignInView para que guarde los tokens en Keychain después del login exitoso.

3.1 Agregar estado de navegación

En SignInView, reemplazamos el estado authTokens por navigateToHome:

struct SignInView: View {
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var isLoading: Bool = false
    @State private var message: String = ""
    @State private var showAlert: Bool = false
    @State private var navigateToRegister: Bool = false
    @State private var navigateToHome: Bool = false  // Nuevo

    private let authService = CognitoAuthService()

¿Qué eliminamos? – authTokens: AuthTokens? – Ya no mostramos tokens en pantalla

¿Qué agregamos? – navigateToHome: Bool – Para navegar a HomeView

3.2 Eliminar visualización de tokens

Eliminamos la sección que mostraba los tokens en pantalla (ya no es necesaria).

3.3 Actualizar la función signIn()

Actualizamos la función para guardar tokens en Keychain:

// Función de Inicio de Sesión
private func signIn() {
    // Validaciones básicas
    guard !email.isEmpty, !password.isEmpty else {
        message = "Por favor completa todos los campos"
        showAlert = true
        return
    }

    // Iniciar sesión con AWS Cognito
    isLoading = true
    message = "Iniciando sesión..."

    Task {
        do {
            let tokens = try await authService.signIn(email: email, password: password)

            // Guardar tokens en Keychain
            let saved = KeychainManager.shared.saveTokens(tokens)

            await MainActor.run {
                isLoading = false

                if saved {
                    message = "✅ Inicio de sesión exitoso"
                    print("✅ Tokens guardados en Keychain")

                    // Limpiar campos
                    email = ""
                    password = ""

                    // Navegar a HomeView
                    navigateToHome = true
                } else {
                    message = "Error al guardar tokens"
                    showAlert = true
                }
            }
        } catch {
            await MainActor.run {
                isLoading = false
                message = error.localizedDescription
                showAlert = true
            }
        }
    }
}

¿Qué cambió?

  1. Guardar tokens: Después de recibir tokens de Cognito, los guardamos en Keychain
  2. let saved = KeychainManager.shared.saveTokens(tokens)
  3. Limpiar campos: Si se guardaron exitosamente, limpiamos email y password
  4. Navegar a HomeView: Activamos navigateToHome = true
  5. Sin visualización: Ya NO guardamos tokens en el estado de la vista

3.4 Agregar navigationDestination para HomeView

Al final de la vista, agregamos el destination:

.navigationDestination(isPresented: $navigateToRegister) {
    RegisterView()
}
.navigationDestination(isPresented: $navigateToHome) {  // Nuevo
    HomeView()
}

Paso 4: Probar el flujo completo

Ahora vamos a probar todo el flujo de almacenamiento seguro y cierre de sesión.

4.1 Probar inicio de sesión y guardado de tokens

  1. Ejecuta la app en el simulador
  2. Inicia sesión:
    • Ingresa email de un usuario confirmado
    • Ingresa contraseña
    • Click en “Iniciar Sesión”
  3. Verifica en la consola de Xcode:
    • ✅ Inicio de sesión exitoso
    • ✅ Guardado en Keychain: cognito.accessToken
    • ✅ Guardado en Keychain: cognito.idToken
    • ✅ Guardado en Keychain: cognito.refreshToken
    • ✅ Tokens guardados en Keychain
  4. Navegación automática:
    • La app navega a HomeView
    • Verás el mensaje “¡Bienvenido!”
    • NO hay botón de “volver” en la navegación

4.2 Probar cierre de sesión

  1. En HomeView, click en “Cerrar Sesión”
  2. Aparece alert de confirmación:
    • “¿Estás seguro que deseas cerrar sesión?”
    • Click en “Cerrar Sesión”
  3. Verifica en la consola:
    • ✅ Eliminado del Keychain: cognito.accessToken
    • ✅ Eliminado del Keychain: cognito.idToken
    • ✅ Eliminado del Keychain: cognito.refreshToken
    • ✅ Tokens eliminados. Sesión cerrada.
  4. Navegación de vuelta:
    • La app regresa a SignInView
    • Los campos están vacíos
    • Los tokens fueron eliminados del Keychain

4.3 Verificar que los tokens se guardaron correctamente

Para verificar que los tokens realmente se guardaron en Keychain:

  1. Inicia sesión
  2. Cierra la app (swipe up en el simulador)
  3. Abre la app de nuevo

Resultado esperado: – Por ahora, la app muestra SignInView (porque aún no implementamos persistencia de sesión) – Pero los tokens SÍ están guardados en Keychain – En el Artículo 5 implementaremos verificar tokens al abrir la app

Para verificar que están guardados, puedes agregar temporalmente este código en viewDidLoad o en SignInView:

// TEMPORAL - solo para verificar
if let tokens = KeychainManager.shared.readTokens() {
    print("✅ Tokens encontrados en Keychain:")
    print("Access: \(tokens.accessToken.prefix(20))...")
} else {
    print("⚠️ No hay tokens guardados")
}

4.4 ¿Qué logramos?

Almacenamiento seguro: Los tokens se guardan en Keychain cifrado ✅ Navegación funcional: Después del login, navega a HomeView ✅ Cerrar sesión: El botón elimina tokens y regresa a login ✅ Seguridad: Usamos kSecAttrAccessibleWhenUnlockedThisDeviceOnly ✅ Sin botón de volver: El usuario no puede volver a HomeView sin hacer login

Lo que AÚN NO tenemos: – ❌ Persistencia de sesión (verificar tokens al abrir la app) – ❌ Refresh automático de tokens cuando expiran – ❌ Pantalla de “loading” al verificar tokens

Estos temas los implementaremos en el Artículo 5.

Conclusión

En este artículo implementamos el almacenamiento seguro de tokens y la funcionalidad de cerrar sesión:

KeychainManager: Clase reutilizable para guardar/leer datos de forma segura

Almacenamiento seguro: Los tokens se guardan en Keychain cifrado por hardware

HomeView: Pantalla de bienvenida después del login

Guardar tokens automáticamente: Después del login, los tokens se guardan sin intervención del usuario

Cerrar sesión: Botón que elimina tokens y regresa a la pantalla de login

Seguridad: Usamos kSecAttrAccessibleWhenUnlockedThisDeviceOnly para máxima seguridad

Navegación inteligente: El usuario no puede volver a HomeView sin hacer login

Flujo completo implementado:

SignInView → Login exitoso → Guardar tokens en Keychain →
HomeView → Cerrar sesión → Eliminar tokens → Volver a SignInView

Progreso de la serie:

Artículo 1: Registro de usuarios ✅

Artículo 2: Confirmación de cuenta ✅

Artículo 3: Inicio de sesión y tokens ✅

Artículo 4: Almacenamiento seguro y cerrar sesión ✅

Artículo 5: Persistencia de sesión (próximo)

En el siguiente artículo implementaremos la persistencia de sesión: cuando el usuario abre la app, verificaremos si hay tokens guardados y lo llevaremos directamente a HomeView sin pedirle que haga login otra vez.

Reflexión final

Te invito a que me sigas en LinkedIn donde comparto contenido relacionado a desarrollo de software, buenas prácticas, y tecnologías AWS. También puedes encontrarme en Medium y explorar mis proyectos en GitHub. Si tienes preguntas o sugerencias, no dudes en conectar – siempre estoy abierto a intercambiar ideas y aprender junto a la comunidad.

Sobre las validaciones

Esta serie de artículos se enfoca en entender cómo funciona AWS Cognito en su forma más pura, por lo que intencionalmente he mantenido las validaciones al mínimo. En un proyecto real de producción, deberías considerar:

Seguridad de Keychain: – Usar Keychain Access Groups para compartir tokens entre apps – Implementar Face ID / Touch ID para acceder a tokens sensibles – Considerar kSecAttrAccessibleAfterFirstUnlock para apps que corren en background – Agregar logs de acceso al Keychain para auditoría – Implementar detección de jailbreak

Gestión de sesión: – Timeout automático de sesión por inactividad – Cierre de sesión forzado en otros dispositivos – Notificación al usuario cuando la sesión expira – Rate limiting en intentos de login – Bloqueo temporal después de N intentos fallidos

Validaciones adicionales: – Verificar integridad de tokens antes de usarlos – Validar firma JWT de los tokens – Comprobar expiración de tokens antes de cada uso – Implementar refresh automático proactivo (antes de que expiren) – Backup y restauración segura de credenciales

Mejores prácticas: – NO guardar contraseñas en Keychain (solo tokens) – Limpiar tokens en memoria después de usarlos – Usar HTTPS siempre para comunicación con AWS – Implementar certificate pinning – Testing de penetración en el almacenamiento – Monitoreo de accesos anómalos

El objetivo es que entiendas primero cómo funciona el almacenamiento seguro en iOS, y luego puedas aplicar las mejores prácticas de seguridad según las necesidades de tu proyecto.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *