El enlace “¿Olvidaste tu contraseña?” parece un detalle menor, pero es una puerta de entrada a todas las cuentas de tu aplicación. Si está mal hecho, un atacante puede quedarse con cualquier cuenta sin conocer la contraseña. En el libro PHP desde cero hasta experto el sistema de login y el envío de correos de recuperación forman parte del recorrido; aquí tienes el flujo completo y seguro.
El flujo completo
- El usuario escribe su email en un formulario.
- Si la cuenta existe, el servidor genera un token aleatorio, guarda su hash con una fecha de caducidad y envía por correo un enlace con el token.
- En cualquier caso, la pantalla muestra el mismo mensaje: “Si el email está registrado, te enviamos un enlace”.
- El usuario abre el enlace, el servidor valida el token y muestra el formulario de nueva contraseña.
- Al guardar, se actualiza la contraseña con
password_hash, se invalida el token y se cierran las sesiones abiertas.
La tabla de tokens
CREATE TABLE password_resets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
usuario_id INT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 en hexadecimal
expira_en DATETIME NOT NULL,
usado_en DATETIME NULL,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE
);
¿Por qué guardar el hash del token y no el token? Por la misma razón que no guardas contraseñas en texto plano: si alguien lee la base de datos (una copia de seguridad filtrada, una inyección SQL), no podrá usar los tokens pendientes.
Paso 1: solicitar el restablecimiento
<?php
declare(strict_types=1);
require_once __DIR__ . '/config/db.php'; // función db(): PDO
require_once __DIR__ . '/lib/correo.php'; // enviarCorreo($para, $asunto, $html): envoltorio de PHPMailer
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$stmt = db()->prepare('SELECT id, nombre FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();
if ($usuario) {
// 32 bytes aleatorios criptográficamente seguros → 64 caracteres hex
$token = bin2hex(random_bytes(32));
// Invalida solicitudes anteriores y guarda la nueva, válida 30 minutos
db()->prepare('DELETE FROM password_resets WHERE usuario_id = ?')->execute([$usuario['id']]);
db()->prepare(
'INSERT INTO password_resets (usuario_id, token_hash, expira_en)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 MINUTE))'
)->execute([$usuario['id'], hash('sha256', $token)]);
$enlace = 'https://tudominio.com/restablecer.php?token=' . $token;
enviarCorreo($email, 'Restablece tu contraseña',
'<p>Para crear una nueva contraseña, abre este enlace (válido 30 minutos):</p>'
. '<p><a href="' . htmlspecialchars($enlace) . '">Restablecer contraseña</a></p>'
. '<p>Si no lo pediste, ignora este correo.</p>'
);
}
// El mismo mensaje exista o no la cuenta
$mensaje = 'Si el email está registrado, te enviamos un enlace para restablecer la contraseña.';
}
$_SERVER['HTTP_HOST']: esa cabecera la controla
quien hace la petición, y un atacante podría lograr que el correo apunte a su propio dominio. La función
de envío la vimos en cómo enviar correos con
PHPMailer.
Paso 2: validar el token y cambiar la contraseña
<?php
declare(strict_types=1);
require_once __DIR__ . '/config/db.php';
$token = $_GET['token'] ?? $_POST['token'] ?? '';
// Busca un token vigente y sin usar
$stmt = db()->prepare(
'SELECT id, usuario_id FROM password_resets
WHERE token_hash = ? AND usado_en IS NULL AND expira_en > NOW()'
);
$stmt->execute([hash('sha256', $token)]);
$reset = $stmt->fetch();
if (!$reset) {
exit('El enlace no es válido o ha caducado. Solicita uno nuevo.');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$clave = $_POST['password'] ?? '';
if (mb_strlen($clave) < 10) {
$error = 'La contraseña debe tener al menos 10 caracteres.';
} else {
$pdo = db();
$pdo->beginTransaction();
$pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([password_hash($clave, PASSWORD_DEFAULT), $reset['usuario_id']]);
$pdo->prepare('UPDATE password_resets SET usado_en = NOW() WHERE id = ?')
->execute([$reset['id']]);
$pdo->commit();
header('Location: login.php?restablecida=1');
exit;
}
}
// ...formulario con un campo oculto "token" y el campo "password"
La transacción garantiza que la contraseña y el token se actualizan juntos: no puede quedar una contraseña cambiada con el token todavía reutilizable.
Errores que lo vuelven inseguro
- Tokens predecibles:
md5(time()),uniqid()orand()se pueden adivinar. Usa siemprerandom_bytes(). - Tokens sin caducidad o reutilizables: un enlace viejo en un correo reenviado sigue abriendo la cuenta.
- Mensajes distintos según exista o no la cuenta: permiten averiguar qué emails están registrados.
- Enviar la contraseña por correo: si puedes enviarla, es que la guardas de forma recuperable. Nunca.
- No limitar las solicitudes: sin un límite por IP o por email, el formulario sirve para bombardear de correos a cualquiera.
- Dejar sesiones abiertas: tras el cambio, cierra las demás sesiones del usuario por si la cuenta ya estaba comprometida.
Login y registro completos
El libro PHP desde cero hasta experto incluye un sistema de login y registro con autenticación segura, y el envío de correos de recuperación de contraseña.
Comprar en Amazon