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:
- Método changePassword – Cambiar contraseña requiriendo la actual
- ChangePasswordView – Pantalla para ingresar contraseña actual + nueva
- Integración en HomeView – Botón de “Cambiar Contraseña” en settings
- Validaciones – Verificar contraseña actual, fuerza de nueva, coincidencia
- 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
| Aspecto | Change Password | Forgot 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 Cognito | changePassword() | forgotPassword() + confirmForgotPassword() |
| Flujo | 1 paso (formulario) | 2 pasos (email → código+password) |
| Casos de uso | Usuario quiere actualizar por seguridad | Usuario olvidó su contraseña |
Mejores prácticas
- Validación en tiempo real:
- Muestra indicadores de fuerza de contraseña mientras escribe
- Checklist de requisitos con feedback visual
- UX de contraseñas:
- Deshabilita botón si no cumple requisitos (previene errores)
- Usa colores claros: verde=bien, rojo=mal, amarillo=regular
- Seguridad:
- Verifica que nueva ≠ actual (previene usuarios perezosos)
- Muestra requisitos claros ANTES de enviar
- Errores:
- Maneja “contraseña incorrecta” sin exponer detalles de seguridad
- Informa sobre límite de intentos
- Persistencia de sesión:
- NO cierres sesión después de cambiar contraseña
- Tokens siguen válidos, usuario sigue logueado
- UI/UX:
- Botón azul “Cambiar Contraseña” + botón rojo “Cerrar Sesión”
- 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:
- ✅ Método changePassword en CognitoAuthService
- Requiere Access Token (usuario logueado)
- Valida contraseña actual con previousPassword
- Error codes: 8000-8004
- ✅ 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
- ✅ Integración en HomeView
- Botón “Cambiar Contraseña” con icono lock.rotation
- Navegación con navigationDestination
- ✅ Validaciones exhaustivas
- Contraseña actual correcta
- Nueva suficientemente fuerte (≥8 caracteres)
- Nueva ≠ actual
- Nueva == confirmar
- ✅ 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