Tema
Recuperar contraseña por correo
Flujo clásico de "¿Olvidaste tu contraseña?": el usuario escribe su correo, recibe un enlace con un token de un solo uso que caduca en 1 hora y elige una contraseña nueva.
Cómo funciona
olvide.phpgenera un token aleatorio, guarda su hash en la base de datos y envía el token original por correo.restablecer.php?token=...busca el hash del token, comprueba que no haya caducado y permite cambiar la contraseña.- Al usarse, el token se borra. Además se borran todos los demás tokens del usuario.
¿Por qué guardar el hash del token?
Si alguien roba tu base de datos, no podrá usar los tokens pendientes para entrar en cuentas ajenas. Es el mismo principio que con las contraseñas.
Tabla
sql
CREATE TABLE password_resets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
usuario_id INT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
expira_en DATETIME NOT NULL,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (token_hash),
FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Paso 1: solicitar el enlace
php
<?php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/mailer.php'; // ver snippet de PHPMailer
$enviado = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$email = strtolower(trim($_POST['email'] ?? ''));
$stmt = db()->prepare('SELECT id, nombre FROM usuarios WHERE email = ? AND activo = 1');
$stmt->execute([$email]);
$u = $stmt->fetch();
if ($u) {
// Limpia solicitudes anteriores del mismo usuario
db()->prepare('DELETE FROM password_resets WHERE usuario_id = ?')->execute([$u['id']]);
$token = bin2hex(random_bytes(32)); // 64 caracteres aleatorios
db()->prepare('INSERT INTO password_resets (usuario_id, token_hash, expira_en)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 1 HOUR))')
->execute([$u['id'], hash('sha256', $token)]);
$enlace = rtrim(env('APP_URL'), '/') . '/restablecer.php?token=' . $token;
$html = '<p>Hola ' . e($u['nombre']) . ',</p>
<p>Recibimos una solicitud para restablecer tu contraseña.</p>
<p><a href="' . e($enlace) . '" style="background:#4f46e5;color:#fff;padding:12px 20px;border-radius:8px;text-decoration:none">Crear nueva contraseña</a></p>
<p>El enlace caduca en 1 hora. Si no fuiste tú, ignora este correo.</p>';
enviar_correo($email, 'Restablece tu contraseña', $html);
}
// Respondemos lo mismo exista o no el correo (no revelamos cuentas)
$enviado = true;
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Recuperar contraseña</title></head>
<body>
<h1>Recuperar contraseña</h1>
<?php if ($enviado): ?>
<p>Si el correo está registrado, recibirás un enlace en unos minutos. Revisa también tu carpeta de spam.</p>
<?php else: ?>
<form method="post">
<?= csrf_field() ?>
<label>Tu correo <input type="email" name="email" required></label>
<button>Enviar enlace</button>
</form>
<?php endif; ?>
</body>
</html>Paso 2: elegir la nueva contraseña
php
<?php
require __DIR__ . '/includes/auth.php';
$token = $_GET['token'] ?? $_POST['token'] ?? '';
$error = '';
// Busca un token vigente
$stmt = db()->prepare('SELECT usuario_id FROM password_resets
WHERE token_hash = ? AND expira_en > NOW() LIMIT 1');
$stmt->execute([hash('sha256', $token)]);
$reset = $stmt->fetch();
if (!$reset) {
exit('El enlace no es válido o ya caducó. <a href="olvide.php">Solicita uno nuevo</a>.');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$pass = $_POST['password'] ?? '';
$pass2 = $_POST['password2'] ?? '';
if (strlen($pass) < 8) {
$error = 'La contraseña debe tener al menos 8 caracteres.';
} elseif ($pass !== $pass2) {
$error = 'Las contraseñas no coinciden.';
} else {
db()->beginTransaction();
db()->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([password_hash($pass, PASSWORD_DEFAULT), $reset['usuario_id']]);
// Invalida todos los tokens del usuario
db()->prepare('DELETE FROM password_resets WHERE usuario_id = ?')
->execute([$reset['usuario_id']]);
db()->commit();
flash('error', 'Contraseña actualizada. Ya puedes iniciar sesión.');
redirect('login.php');
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Nueva contraseña</title></head>
<body>
<h1>Nueva contraseña</h1>
<?php if ($error): ?><p style="color:#b91c1c"><?= e($error) ?></p><?php endif; ?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="token" value="<?= e($token) ?>">
<label>Nueva contraseña <input type="password" name="password" minlength="8" required></label>
<label>Repítela <input type="password" name="password2" required></label>
<button>Guardar</button>
</form>
</body>
</html>Limpieza automática (opcional)
Programa un cron diario en cPanel para borrar tokens vencidos:
sql
DELETE FROM password_resets WHERE expira_en < NOW();Cómo integrarlo
- Ten listos la conexión PDO, la autenticación y el envío de correo.
- Define
APP_URLen tu.envcon el dominio real (conhttps://). - Enlaza
olvide.phpdesde tu formulario de login.
Error común
Usar md5(time()) o uniqid() como token. Son predecibles. Usa siempre random_bytes().
Seguridad
No envíes la contraseña nueva por correo ni "generes una temporal". El usuario siempre debe elegirla él mismo a través de un enlace caducable.