Skip to content

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 ​

  1. olvide.php genera un token aleatorio, guarda su hash en la base de datos y envía el token original por correo.
  2. restablecer.php?token=... busca el hash del token, comprueba que no haya caducado y permite cambiar la contraseña.
  3. 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 ​

  1. Ten listos la conexión PDO, la autenticación y el envío de correo.
  2. Define APP_URL en tu .env con el dominio real (con https://).
  3. Enlaza olvide.php desde 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.

Academia Web — aprende, copia y construye.