Cambio de contraseña con AWS Cognito en iOS

En el artículo anterior implementamos el flujo de recuperación de contraseña para usuarios que olvidaron su password. Pero ¿qué pasa cuando un usuario SÍ está logueado y quiere cambiar su contraseña por seguridad o políticas empresariales? En este artículo implementaremos el flujo de “Change Password” para usuarios autenticados.

¿Qué vamos a construir?

En este artículo implementaremos:

  1. Método changePassword – Cambiar contraseña requiriendo la actual
  2. ChangePasswordView – Pantalla para ingresar contraseña actual + nueva
  3. Integración en HomeView – Botón de “Cambiar Contraseña” en settings
  4. Validaciones – Verificar contraseña actual, fuerza de nueva, coincidencia
  5. Manejo de errores – Contraseña incorrecta, débil, etc.

Diferencia con Forgot Password

Forgot Password (Artículo 8): – Usuario NO está logueado – NO recuerda su contraseña – Requiere código por email – NO requiere contraseña actual – Debe hacer login después

Change Password (Artículo 9 – este): – Usuario SÍ está logueado – SÍ recuerda su contraseña – NO requiere código por email – SÍ requiere contraseña actual – Sigue logueado después

El flujo de Change Password

Usuario logueado en HomeView
    ↓
Presiona «Cambiar Contraseña»
    ↓
ChangePasswordView
    ↓
Ingresar: Contraseña ACTUAL + Nueva contraseña (x2)
    ↓
Validar: Contraseña actual correcta + Nueva suficientemente fuerte
    ↓
changePassword(accessToken, currentPassword, newPassword)
    ↓
Cognito valida contraseña actual y actualiza
    ↓
✅ Contraseña cambiada
    ↓
Usuario SIGUE logueado (no cierra sesión)
    ↓
Vuelve a HomeView

Requisitos previos

  • Haber completado los artículos anteriores (especialmente Artículo 3: Login)
  • Tener CognitoAuthService implementado
  • Tener HomeView con navegación
  • Usuario logueado con tokens en Keychain

Paso 1: Agregar método changePassword en CognitoAuthService

Primero agregamos el método para cambiar contraseña en CognitoAuthService.swift:

// Cambio de Contraseña (Change Password)

/// Cambia la contraseña del usuario autenticado
/// - Parameters:
///   - currentPassword: La contraseña actual del usuario
///   - newPassword: La nueva contraseña
/// - Returns: Mensaje de confirmación
func changePassword(currentPassword: String, newPassword: String) async throws -> String {
    guard let client = cognitoClient else {
        throw NSError(domain: "CognitoAuthService", code: -1,
                     userInfo: [NSLocalizedDescriptionKey: "Cliente de Cognito no inicializado"])
    }

    // Obtener Access Token del usuario logueado
    guard let accessToken = KeychainManager.shared.readTokens()?.accessToken else {
        throw NSError(domain: "CognitoAuthService", code: 8001,
                     userInfo: [NSLocalizedDescriptionKey: "No hay sesión activa. Por favor inicia sesión"])
    }

    // Crear la solicitud de cambio de contraseña
    let changePasswordInput = ChangePasswordInput(
        accessToken: accessToken,
        previousPassword: currentPassword,
        proposedPassword: newPassword
    )

    do {
        _ = try await client.changePassword(input: changePasswordInput)

        print("✅ Contraseña cambiada exitosamente")

        return "Contraseña actualizada exitosamente"

    } catch {
        print("❌ Error al cambiar contraseña: \(error)")

        let errorMessage = error.localizedDescription
        if errorMessage.contains("NotAuthorizedException") {
            throw NSError(domain: "CognitoAuthService", code: 8002,
                        userInfo: [NSLocalizedDescriptionKey: "Contraseña actual incorrecta"])
        } else if errorMessage.contains("InvalidPasswordException") {
            throw NSError(domain: "CognitoAuthService", code: 8003,
                        userInfo: [NSLocalizedDescriptionKey: "La nueva contraseña no cumple con los requisitos mínimos"])
        } else if errorMessage.contains("LimitExceededException") {
            throw NSError(domain: "CognitoAuthService", code: 8004,
                        userInfo: [NSLocalizedDescriptionKey: "Demasiados intentos. Espera unos minutos e intenta de nuevo"])
        } else {
            throw NSError(domain: "CognitoAuthService", code: 8000,
                        userInfo: [NSLocalizedDescriptionKey: "Error al cambiar contraseña: \(errorMessage)"])
        }
    }
}

Puntos clave:

✅ Requiere Access Token Usuario DEBE estar logueado

✅ Valida contraseña actual con previousPassword

✅ Actualiza con proposedPassword

✅ Maneja errores específicos (contraseña incorrecta, débil, límite)

✅ Usa códigos de error 8000-8004

Paso 2: Crear ChangePasswordView

Ahora creamos la vista para cambiar la contraseña. Crea el archivo ChangePasswordView.swift:

import SwiftUI

struct ChangePasswordView: View {
    @Environment(\.dismiss) private var dismiss

    @State private var currentPassword: String = ""
    @State private var newPassword: String = ""
    @State private var confirmPassword: String = ""

    @State private var isLoading: Bool = false
    @State private var message: String = ""
    @State private var showAlert: Bool = false

    private let authService = CognitoAuthService()

    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // Header
                headerView

                // Formulario
                formView

                Spacer()
            }
            .padding(.horizontal, 30)
            .padding(.top, 50)
        }
        .navigationTitle("Cambiar Contraseña")
        .navigationBarTitleDisplayMode(.inline)
        .alert("Cambiar Contraseña", isPresented: $showAlert) {
            Button("OK", role: .cancel) {
                // Si el cambio fue exitoso, volver atrás
                if message.contains("actualizada exitosamente") {
                    dismiss()
                }
            }
        } message: {
            Text(message)
        }
    }

    //  Header
    private var headerView: some View {
        VStack(spacing: 10) {
            Image(systemName: "lock.rotation")
                .font(.system(size: 60))
                .foregroundColor(.blue)

            Text("Actualiza tu contraseña")
                .font(.title3)
                .fontWeight(.semibold)

            Text("Ingresa tu contraseña actual y la nueva contraseña")
                .font(.subheadline)
                .foregroundColor(.gray)
                .multilineTextAlignment(.center)
        }
        .padding(.bottom, 20)
    }

    // Formulario
    private var formView: some View {
        VStack(spacing: 20) {
            // Campo de Contraseña Actual
            VStack(alignment: .leading, spacing: 8) {
                Text("Contraseña Actual")
                    .font(.caption)
                    .foregroundColor(.gray)

                SecureField("Ingresa tu contraseña actual", text: $currentPassword)
                    .padding()
                    .background(Color(.systemGray6))
                    .cornerRadius(10)
                    .disabled(isLoading)
            }

            // Campo de Nueva Contraseña
            VStack(alignment: .leading, spacing: 8) {
                Text("Nueva Contraseña")
                    .font(.caption)
                    .foregroundColor(.gray)

                SecureField("Ingresa tu nueva contraseña", text: $newPassword)
                    .padding()
                    .background(Color(.systemGray6))
                    .cornerRadius(10)
                    .disabled(isLoading)

                // Indicador de fuerza de contraseña
                if !newPassword.isEmpty {
                    passwordStrengthIndicator
                }
            }

            // Campo de Confirmar Nueva Contraseña
            VStack(alignment: .leading, spacing: 8) {
                Text("Confirmar Nueva Contraseña")
                    .font(.caption)
                    .foregroundColor(.gray)

                SecureField("Confirma tu nueva contraseña", text: $confirmPassword)
                    .padding()
                    .background(Color(.systemGray6))
                    .cornerRadius(10)
                    .disabled(isLoading)

                // Indicador de coincidencia
                if !newPassword.isEmpty && !confirmPassword.isEmpty {
                    passwordMatchIndicator
                }
            }

            // Mensaje de estado
            if !message.isEmpty && !showAlert {
                Text(message)
                    .font(.caption)
                    .foregroundColor(.gray)
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
            }

            // Botón de Cambiar Contraseña
            Button(action: {
                changePassword()
            }) {
                if isLoading {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle(tint: .white))
                        .frame(maxWidth: .infinity)
                        .padding()
                } else {
                    Text("Cambiar Contraseña")
                        .fontWeight(.semibold)
                        .foregroundColor(.white)
                        .frame(maxWidth: .infinity)
                        .padding()
                }
            }
            .background(isLoading ? Color.gray : Color.blue)
            .cornerRadius(10)
            .disabled(isLoading || !isFormValid)
            .padding(.top, 10)

            // Requisitos de contraseña
            passwordRequirementsView
        }
    }

    //  Indicador de Fuerza de Contraseña
    private var passwordStrengthIndicator: some View {
        let strength = getPasswordStrength(newPassword)

        return HStack(spacing: 4) {
            ForEach(0..<4) { index in
                Rectangle()
                    .fill(index < strength.level ? strength.color : Color.gray.opacity(0.3))
                    .frame(height: 4)
                    .cornerRadius(2)
            }

            Spacer()

            Text(strength.text)
                .font(.caption2)
                .foregroundColor(strength.color)
        }
    }

    // Indicador de Coincidencia
    private var passwordMatchIndicator: some View {
        HStack {
            Image(systemName: newPassword == confirmPassword ? "checkmark.circle.fill" : "xmark.circle.fill")
                .foregroundColor(newPassword == confirmPassword ? .green : .red)
            Text(newPassword == confirmPassword ? "Las contraseñas coinciden" : "Las contraseñas no coinciden")
                .font(.caption)
                .foregroundColor(newPassword == confirmPassword ? .green : .red)
        }
    }

    // Requisitos de Contraseña
    private var passwordRequirementsView: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Requisitos de contraseña:")
                .font(.caption)
                .foregroundColor(.gray)
                .fontWeight(.semibold)

            requirementRow(met: newPassword.count >= 8, text: "Mínimo 8 caracteres")
            requirementRow(met: newPassword.contains(where: { $0.isUppercase }), text: "Al menos una letra mayúscula")
            requirementRow(met: newPassword.contains(where: { $0.isLowercase }), text: "Al menos una letra minúscula")
            requirementRow(met: newPassword.contains(where: { $0.isNumber }), text: "Al menos un número")
        }
        .padding()
        .background(Color(.systemGray6).opacity(0.5))
        .cornerRadius(10)
    }

    private func requirementRow(met: Bool, text: String) -> some View {
        HStack {
            Image(systemName: met ? "checkmark.circle.fill" : "circle")
                .foregroundColor(met ? .green : .gray)
                .font(.caption)
            Text(text)
                .font(.caption)
                .foregroundColor(met ? .green : .gray)
        }
    }

    // Validación del Formulario
    private var isFormValid: Bool {
        !currentPassword.isEmpty &&
        !newPassword.isEmpty &&
        !confirmPassword.isEmpty &&
        newPassword == confirmPassword &&
        newPassword.count >= 8
    }

    // Función de Cambio de Contraseña
    private func changePassword() {
        // Validaciones
        guard !currentPassword.isEmpty else {
            message = "Por favor ingresa tu contraseña actual"
            showAlert = true
            return
        }

        guard !newPassword.isEmpty else {
            message = "Por favor ingresa tu nueva contraseña"
            showAlert = true
            return
        }

        guard newPassword == confirmPassword else {
            message = "Las contraseñas no coinciden"
            showAlert = true
            return
        }

        guard newPassword.count >= 8 else {
            message = "La contraseña debe tener al menos 8 caracteres"
            showAlert = true
            return
        }

        guard newPassword != currentPassword else {
            message = "La nueva contraseña debe ser diferente a la actual"
            showAlert = true
            return
        }

        isLoading = true
        message = "Cambiando contraseña..."

        Task {
            do {
                let response = try await authService.changePassword(
                    currentPassword: currentPassword,
                    newPassword: newPassword
                )

                await MainActor.run {
                    isLoading = false
                    message = response
                    showAlert = true

                    print("✅ Contraseña cambiada exitosamente")
                }
            } catch {
                await MainActor.run {
                    isLoading = false
                    message = error.localizedDescription
                    showAlert = true
                }
            }
        }
    }

    // Helper: Password Strength
    private func getPasswordStrength(_ password: String) -> (level: Int, color: Color, text: String) {
        var strength = 0

        if password.count >= 8 { strength += 1 }
        if password.contains(where: { $0.isUppercase }) { strength += 1 }
        if password.contains(where: { $0.isLowercase }) { strength += 1 }
        if password.contains(where: { $0.isNumber }) { strength += 1 }
        if password.contains(where: { "!@#$%^&*()_+-=[]{}|;:,.<>?".contains($0) }) { strength += 1 }

        switch strength {
        case 0...1: return (1, .red, "Débil")
        case 2: return (2, .orange, "Regular")
        case 3: return (3, .yellow, "Buena")
        case 4...: return (4, .green, "Fuerte")
        default: return (0, .gray, "")
        }
    }
}

Características de la vista:

✅ 3 campos: Contraseña actual, nueva, confirmar nueva

✅ Indicador visual de fuerza de contraseña (4 barras con colores)

✅ Indicador de coincidencia de contraseñas

✅ Checklist de requisitos en tiempo real

✅ Validaciones: campos vacíos, coincidencia, longitud mínima, nueva ≠ actual

✅ Alert de éxito/error con auto-dismiss en éxito

Paso 3: Integrar en HomeView

Ahora agregamos la navegación a ChangePasswordView desde HomeView. Modifica HomeView.swift:

struct HomeView: View {
    @Binding var isLoggedIn: Bool
    @State private var showingSignOut = false
    @State private var navigateToChangePassword = false  // ← Nuevo

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

            // ... (iconos y textos de bienvenida)

            Spacer()

            // Botón de cambiar contraseña  ← Nuevo
            Button(action: {
                navigateToChangePassword = true
            }) {
                Label("Cambiar Contraseña", systemImage: "lock.rotation")
                    .fontWeight(.semibold)
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .cornerRadius(10)
            }
            .padding(.horizontal, 30)

            // 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)
        .navigationDestination(isPresented: $navigateToChangePassword) {  // ← Nuevo
            ChangePasswordView()
        }
        .alert("Cerrar Sesión", isPresented: $showingSignOut) {
            // ... (código existente)
        }
    }

    // ... (resto del código)
}

Integración:

✅ Botón “Cambiar Contraseña” en HomeView

✅ Usa icono lock.rotation

✅ Color azul para diferenciarlo de “Cerrar Sesión” (rojo)

✅ Navegación con navigationDestination

Paso 4: Probar el flujo completo

Ahora probemos el flujo:

Compilar:

xcodebuild -project CognitoAuthDemo.xcodeproj -scheme CognitoAuthDemo -destination ‘platform=iOS Simulator,name=iPhone 16’ clean build

Deberías ver: ** BUILD SUCCEEDED **

Escenarios de prueba

1. Cambio exitoso

Pasos: 1. Inicia sesión con un usuario existente 2. En HomeView, presiona “Cambiar Contraseña” 3. Ingresa: – Contraseña actual: Password123 (tu contraseña actual) – Nueva contraseña: NewPass456 – Confirmar: NewPass456 4. Presiona “Cambiar Contraseña”

Resultado esperado:

✅ Alert: “Contraseña actualizada exitosamente”

✅ Al presionar OK, vuelve a HomeView

✅ Usuario SIGUE logueado (no se cierra sesión)

✅ Puedes cerrar sesión y volver a iniciar con la NUEVA contraseña

Console:

✅ Contraseña cambiada exitosamente

2. Contraseña actual incorrecta

Pasos: 1. Ingresa contraseña actual INCORRECTA: WrongPass123 2. Nueva contraseña: NewPass456 3. Confirmar: NewPass456 4. Presiona “Cambiar Contraseña”

Resultado esperado:

❌ Alert: “Contraseña actual incorrecta”

❌ No cambia la contraseña

✅ Permanece en ChangePasswordView para reintentar

3. Nueva contraseña débil

Pasos: 1. Contraseña actual correcta: Password123 2. Nueva contraseña DÉBIL: abc (muy corta) 3. Confirmar: abc 4. Presiona “Cambiar Contraseña”

Resultado esperado:

❌ Botón DESHABILITADO (gris) porque no cumple validación de 8+ caracteres

❌ Indicador de fuerza muestra “Débil” en rojo

❌ Checklist muestra ❌ en requisitos no cumplidos

4. Contraseñas no coinciden

Pasos: 1. Contraseña actual: Password123 2. Nueva contraseña: NewPass456 3. Confirmar: NewPass789 (diferente)

Resultado esperado:

❌ Indicador rojo: “Las contraseñas no coinciden”

❌ Botón “Cambiar Contraseña” DESHABILITADO

✅ Indicador se vuelve verde cuando coinciden

5. Nueva contraseña igual a la actual

Pasos: 1. Contraseña actual: Password123 2. Nueva contraseña: Password123 (misma) 3. Confirmar: Password123 4. Presiona “Cambiar Contraseña”

Resultado esperado:

❌ Alert: “La nueva contraseña debe ser diferente a la actual”

6. Demasiados intentos

Pasos: 1. Intenta cambiar la contraseña con contraseña actual incorrecta 5-6 veces seguidas

Resultado esperado:

❌ Alert: “Demasiados intentos. Espera unos minutos e intenta de nuevo” – 🕒 Esperar 1-2 minutos antes de reintentar

7. Sin sesión activa

Pasos: 1. Si de alguna forma los tokens se eliminan del Keychain mientras estás en ChangePasswordView 2. Intenta cambiar la contraseña

Resultado esperado: – ❌ Alert: “No hay sesión activa. Por favor inicia sesión”

Diferencias clave: Change Password vs Forgot Password

AspectoChange PasswordForgot Password
Usuario logueado✅ Sí, requiere Access Token❌ No, usa solo email
Recuerda contraseña✅ Sí, la ingresa❌ No, por eso la olvidó
Requiere código❌ No✅ Sí, por email
Valida contraseña actual✅ Sí❌ No
Cierra sesión después❌ No, sigue logueado⚠️ Debe hacer login nuevo
API de CognitochangePassword()forgotPassword() + confirmForgotPassword()
Flujo1 paso (formulario)2 pasos (email → código+password)
Casos de usoUsuario quiere actualizar por seguridadUsuario olvidó su contraseña

Mejores prácticas

  1. Validación en tiempo real:
    1. Muestra indicadores de fuerza de contraseña mientras escribe
    1. Checklist de requisitos con feedback visual
  2. UX de contraseñas:
    1. Deshabilita botón si no cumple requisitos (previene errores)
    1. Usa colores claros: verde=bien, rojo=mal, amarillo=regular
  3. Seguridad:
    1. Verifica que nueva ≠ actual (previene usuarios perezosos)
    1. Muestra requisitos claros ANTES de enviar
  4. Errores:
    1. Maneja “contraseña incorrecta” sin exponer detalles de seguridad
    1. Informa sobre límite de intentos
  5. Persistencia de sesión:
    1. NO cierres sesión después de cambiar contraseña
    1. Tokens siguen válidos, usuario sigue logueado
  6. UI/UX:
    1. Botón azul “Cambiar Contraseña” + botón rojo “Cerrar Sesión”
    1. Icon lock.rotation es intuitivo para cambio de contraseña

Errores comunes y soluciones

Error 1: “No hay sesión activa”

Causa: Tokens no están en Keychain o expiraron Solución: Asegúrate de que el usuario haya iniciado sesión. Si los tokens expiraron, usa el flujo de refresh del Artículo 6.

Error 2: Botón siempre deshabilitado

Causa: Validación isFormValid es muy estricta Solución: Verifica que: – Nueva contraseña ≥ 8 caracteres – Nueva == Confirmar – Todos los campos llenos

Error 3: Alert no dismissea vista

Causa: Condición en el Button del alert no detecta éxito Solución: Verifica que el mensaje contenga “actualizada exitosamente”

Error 4: Contraseña cambia pero UI no responde

Causa: No usaste await MainActor.run para actualizar @State Solución: Siempre actualiza UI desde MainActor

¿Qué sigue?

Has completado el flujo de Change Password. Ahora los usuarios pueden:

✅ Cambiar su contraseña cuando estén logueados

✅ Ver feedback visual en tiempo real

✅ Permanecer logueados después del cambio

En el siguiente artículo implementaremos Cerrar sesión global (Global Sign Out), que invalida todos los tokens del usuario en todos los dispositivos.

Resumen

En este artículo implementamos:

  1. Método changePassword en CognitoAuthService
    • Requiere Access Token (usuario logueado)
    • Valida contraseña actual con previousPassword
    • Error codes: 8000-8004
  2. ChangePasswordView con UI completa
    • 3 campos: actual, nueva, confirmar
    • Indicador visual de fuerza (4 barras de colores)
    • Checklist de requisitos en tiempo real
    • Indicador de coincidencia
  3. Integración en HomeView
    • Botón “Cambiar Contraseña” con icono lock.rotation
    • Navegación con navigationDestination
  4. Validaciones exhaustivas
    • Contraseña actual correcta
    • Nueva suficientemente fuerte (≥8 caracteres)
    • Nueva ≠ actual
    • Nueva == confirmar
  5. Manejo de errores
    • Contraseña incorrecta
    • Contraseña débil
    • Límite de intentos

Diferencia clave con Forgot Password:Change Password: Usuario logueado, recuerda contraseña, NO requiere código – Forgot Password: Usuario NO logueado, NO recuerda contraseña, requiere código por email

Sobre el autor

José Luján es un desarrollador móvil especializado en iOS, Android y desarrollo multiplataforma (Flutter). Experto en integración de servicios AWS en aplicaciones móviles y autor de múltiples artículos técnicos. También forma parte del programa Amazon Community Builders.

Conéctate conmigo: – LinkedIn: https://www.linkedin.com/in/josed-lujan/ – GitHub: https://github.com/josedlucas – Beacons: https://beacons.ai/josedlujan

Deja una respuesta

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