web

Manejador de sesiones propio en PHP y MySQL

Introducción

Vamos a ver hoy cómo implementar un manejador de sesiones en PHP hecho por nosotros mismos para poder entender a fondo cómo funcionan.

Como todos sabemos, PHP provee el uso de sesiones cuya persistencia es lograda a través de archivos en el sistema.

Es decir, guarda y lee los datos de las sesiones en archivos (imaginemos que lo hace en ficheros txt para darnos una pequeña idea).

Esto está bien si no usaremos las sesiones en múltiples peticiones AJAX, en donde puede desatarse una lectura concurrente del archivo, lo que ocasionará bloqueos.

Debido a que PHP bloquea el fichero cuando se está leyendo, si se desea abrir el mismo, se generará un error. Por ello es que hoy veremos cómo implementar nuestro propio manejador de sesiones.

Será implementado en MySQL utilizando PDO. Vamos allá.

Antes de todo

Recomiendo que el lector de este post esté familiarizado con las sesiones y con la conexión entre MySQL y PHP. Aquí dejo algunos sitios útiles:

Pequeña introducción a las sesiones

Antes de crear nuestro propio manejador de sesiones seguro, debemos entender lo que es una sesión y su funcionamiento.

Una sesión es algo que persiste en el servidor incluso cuando el usuario abandona la página. Se identifica por medio de un ID, por ejemplo 123.

Este id, la mayoría de veces (aunque hay mejores opciones) es almacenado en una cookie de nuestro navegador.

Cuando visitamos un sitio, el navegador envía esa cookie y el servidor busca si la tiene registrada. En caso de que sí, entonces llena el arreglo superglobal $_SESSION y con eso el programador puede trabajar.

En caso de que no exista, o no hayan datos, no habrá nada en el arreglo superglobal.

Luego, como programadores, comprobamos con algo así:

<?php
if(isset($_SESSION["usuario"])){
 //Deja que pase
}else{
  //No permitido, por favor inicia sesión
}

Y todo va bien.

Eso fue una pequeña introducción, si quieres saber más sobre sesiones de PHP haz click aquí. Ahora sí vamos al código.

Código fuente

He aquí el código. Si quieres la explicación, continúa leyendo 🙂

<?php
/*
Manejador de sesiones propio
Recuerda crear una tabla así:

CREATE TABLE IF NOT EXISTS sesiones(
    id VARCHAR(255) NOT NULL PRIMARY KEY,
    datos TEXT NOT NULL,
    ultimo_acceso BIGINT UNSIGNED NOT NULL
);

@author parzibyte
@see parzibyte.me/blog
@date 2018-06-28

 */class ManejadorDeSesion implements \SessionHandlerInterface
{
    private $base_de_datos; #Aquí vamos a guardar nuestra referencia a la base de datos
    public function open($ruta_de_guardado, $nombre_de_sesion)
    {
        $pass = ""; // Contraseña del servidor MySQL
        $usuario = "root"; // Usuario de MySQL
        $nombre_base_de_datos = "test"; // El nombre de la base de datos (no el de la tabla)
        $host = "localhost"; // El host, normalmente localhost pero puede ser una IP en algunos casos
        try {
            $this->base_de_datos = new PDO('mysql:host=' . $host
                . ';dbname=' . $nombre_base_de_datos,
                $usuario, $pass);
            return true;
        } catch (Exception $e) {
            //TODO: Imprimir o guardar el mensaje en un log
            return false;
        }
    }

    public function close()
    {
        #Eliminamos referencia a la base de datos
        $this->base_de_datos = null;
        return true;
    }

    public function write($id_de_sesion, $datos_de_sesion)
    {
        $ultimo_acceso = time();
        $sentencia = $this->base_de_datos->prepare("REPLACE INTO sesiones (id, datos, ultimo_acceso) VALUES (?, ?, ?);");
        return $sentencia->execute([$id_de_sesion, $datos_de_sesion, $ultimo_acceso]);
    }

    public function read($id_de_sesion)
    {
        $sentencia = $this->base_de_datos->prepare("SELECT datos FROM sesiones WHERE id = ?;");
        $sentencia->execute([$id_de_sesion]);
        # Recuperar como objeto (con PDO::FETCH_OBJ), para acceder a $fila->datos
        $fila = $sentencia->fetch(PDO::FETCH_OBJ);

        # Si no existen datos con ese id, fetch devuelve FALSE
        if ($fila === false) {
            return ""; # Cadena vacía
        } else {
            return $fila->datos;
        }
    }

    public function destroy($id_de_sesion)
    {
        $sentencia = $this->base_de_datos->prepare("DELETE FROM sesiones WHERE id = ?;");
        return $sentencia->execute([$id_de_sesion]);
    }

    public function gc($tiempo_de_vida)
    {
        #Calculamos el tiempo actual menos el tiempo de vida.
        $caducidad = time() - $tiempo_de_vida;

        $sentencia = $this->base_de_datos->prepare("DELETE FROM sesiones WHERE ultimo_acceso < ?;");
        return $sentencia->execute([$caducidad]);
    }

}

Preparar base de datos y crear tabla

Vamos a loguearnos en nuestro servidor MySQL y crearemos la siguiente tabla:

CREATE TABLE IF NOT EXISTS sesiones(
    id VARCHAR(255) NOT NULL PRIMARY KEY,
    datos TEXT NOT NULL,
    ultimo_acceso BIGINT UNSIGNED NOT NULL
);

En este caso mi base de datos se llama test, tú puedes usar la tuya, pero recuerda cambiar las credenciales en el código PHP.

Id

Este será el id de sesión, una cadena aleatoria como 21312hsk12wk21jw21321. Lo dejamos en 255 para no quedar cortos de espacio

Datos

Lo almacenamos como texto, pues serán los datos serializados. Es decir, los datos que guardemos serán “planchados”.

Para darnos una idea, veamos lo siguiente…

Un lindo arreglo como:

<?php
$canciones = [
    [
        "nombre" => "Bohemian Rhapsody",
        "duracion" => 5.54,
        "autor" => "Queen",
    ],
    [
        "nombre" => "Sultans Of Swing",
        "duracion" => 5.48,
        "autor" => "Dire Straits",
    ],
    [
        "nombre" => "Antisocial",
        "duracion" => 5.09,
        "autor" => "Trust",
    ],
];

Se serializará  y se convertirá en algo como:

a:3:{i:0;a:3:{s:6:”nombre”;s:17:”Bohemian Rhapsody”;s:8:”duracion”;d:5.54;s:5:”autor”;s:5:”Queen”;}i:1;a:3:{s:6:”nombre”;s:16:”Sultans Of Swing”;s:8:”duracion”;d:5.48;s:5:”autor”;s:12:”Dire Straits”;}i:2;a:3:{s:6:”nombre”;s:10:”Antisocial”;s:8:”duracion”;d:5.09;s:5:”autor”;s:5:”Trust”;}}

Esto es convertir cualquier tipo de dato complicado en una cadena, para almacenarla. Para recuperar los datos simplemente hacemos el proceso inverso y listo.

Nota: dije “algo como” porque el algoritmo que utiliza PHP cuando serializa los datos de sesión es diferente al que utilicé para el ejemplo, pero el resultado es similar. Si quieres puedes probar llamando a la función serialize y pasándole algún dato 😉

Último acceso

Esto sirve para 2 cosas hasta donde yo sé. Una de ellas es para que podamos hacer cálculos y eliminar las sesiones viejas cuando el recolector de basura nos lo pida.

También podemos, gracias a este dato, saber cuándo fue la última vez que determinado usuario se logueó o realizó algún movimiento en la sesión.

Implementando interfaz para escribir nuestro manejador de sesiones

Creamos una clase que va a implementar la interfaz SessionHandlerInterface. Así:

<?php
class ManejadorDeSesion implements \SessionHandlerInterface{
    private $base_de_datos; #Aquí vamos a guardar nuestra referencia a la base de datos
}

Recordemos que una interfaz está hecha para que sus métodos sean sobrescritos. Vamos a implementar, por lo tanto, los métodos:

  • close
  • destroy
  • gc
  • open
  • read
  • write

No daré una descripción detallada de cómo funcionan los métodos, aquí dejo la referencia.

Sobrescribiendo métodos de la interfaz

Método open

Este es el método que es llamado siempre, lo que hace es abrir el almacenamiento de la sesión. Aquí es en donde vamos a instanciar nuestra base de datos.

Debemos devolver un booleano indicando si el almacenamiento pudo ser abierto o no.

<?php
public function open($ruta_de_guardado, $nombre_de_sesion)
{
    $pass = ""; // Contraseña del servidor MySQL
    $usuario = "root"; // Usuario de MySQL
    $nombre_base_de_datos = "test"; // El nombre de la base de datos (no el de la tabla)
    $host = "localhost"; // El host, normalmente localhost pero puede ser una IP en algunos casos
    try {
        $this->base_de_datos = new PDO('mysql:host=' . $host
            . ';dbname=' . $nombre_base_de_datos,
            $usuario, $pass);
        return true;
    } catch (Exception $e) {
        //TODO: Imprimir o guardar el mensaje en un log
        return false;
    }
}

Esta función es llamada con la ruta de guardado y el nombre de la sesión. La ruta de guardado es la ruta del directorio en donde se guardan las sesiones por defecto (por ejemplo C:\users\tmp ).

El segundo parámetro es el nombre de la sesión. Por defecto es PHPSESSID, y este identificador también se usa en las cookies para identificarnos.

No vamos a utilizar ninguno de estos parámetros, ya que nosotros vamos a guardar todo en MySQL. El código se explica pero igual aquí dejo la lista de variables

  • $pass: la contraseña con la que accedemos a nuestro servidor. Déjala en blanco si no tienes contraseña, pero ponla en caso contrario.
  • $usuario: el usuario, por ejemplo root.
  • $nombre_base_de_datos: el nombre de nuestra base de datos. En este caso es test, pero si usas otra recuerda cambiarla aquí.
  • $host: el host en donde MySQL escucha, que normalmente es localhost. Pero en determinados casos podemos utilizar MySQL remoto, así que cambiaríamos el host por una ip como 152.32.63.55.

Intentamos instanciar el objeto y si no se genera ninguna excepción regresamos true. En caso de que no, atrapamos la excepción y regresamos false.

Notar por favor que esta excepción debe ser manejada por nosotros. Se me ocurre escribirla en un log.

Método close

Cuando algo se abre también se tiene que cerrar. Cuando PHP deja de leer o escribir en la sesión, llama a este método. Aquí simplemente regresamos true y eliminamos las referencias a nuestra base de datos.

<?php
public function close()
{
    #Eliminamos referencia a la base de datos
    $this->base_de_datos = null;
    return true;
}

¿por qué eliminar la referencia a la base de datos, no se elimina por sí sola? claro que sí, pero al final de nuestro script. Hay casos en los que leemos o escribimos en la sesión y realizamos otras actividades.

Si dejamos abierta la conexión y hacemos algunas otras tareas, la conexión quedará abierta, gastando recursos. Mejor prevenir que lamentar 🙂

Método write

Este método es llamado cuando se hace un update o un insert. Es decir, cuando iniciamos sesión por primera vez, algo así:

<?php
session_start();
$_SESSION["nombre"] = "Luis";

Y también cuando actualizamos un dato, por ejemplo, si ya habíamos iniciado la sesión antes pero vamos a actualizar el nombre hacemos esto:

<?php
session_start();
$_SESSION["nombre"] = "Luis";

Puede que no notemos la diferencia, pero primero insertamos y en el segundo caso sólo actualizamos. PHP llama a este método en ambos casos. He aquí el código:

<?php
public function write($id_de_sesion, $datos_de_sesion)
{
    $ultimo_acceso = time();
    $sentencia = $this->base_de_datos->prepare("REPLACE INTO sesiones (id, datos, ultimo_acceso) VALUES (?, ?, ?);");
    return $sentencia->execute([$id_de_sesion, $datos_de_sesion, $ultimo_acceso]);
}

Hacemos un REPLACE INTO, que es algo como decirle al servidor: si ya existe el id que te estoy pasando, sólo actualiza los datos. Pero si no, inserta uno nuevo.

Por cierto, veamos que PHP nos pasa los parámetros id de sesión y datos de sesión. Los datos son una cadena serializada de los verdaderos datos (qué redundancia).

Ah, y también ponemos el último acceso llamando a time. Eso devuelve el número de segundos transcurridos desde la fecha Unix, o el 1 de enero de 1970.

Método read

PHP siempre esperará una cadena (aunque sea vacía) como resultado de llamar a este método. Nos pasa como parámetro el id de la sesión.

Lo buscamos en nuestra base de datos. Si existe, devolvemos los datos serializados (PHP se encarga de des-serializarlos y llenar el arreglo superglobal $_SESSION por nosotros).

Pero si no existe, entonces regresamos "".

<?php
public function read($id_de_sesion)
{
    $sentencia = $this->base_de_datos->prepare("SELECT datos FROM sesiones WHERE id = ?;");
    $sentencia->execute([$id_de_sesion]);
    # Recuperar como objeto (con PDO::FETCH_OBJ), para acceder a $fila->datos
    $fila = $sentencia->fetch(PDO::FETCH_OBJ);

    # Si no existen datos con ese id, fetch devuelve FALSE
    if ($fila === false) {
        return ""; # Cadena vacía
    } else {
        return $fila->datos;
    }
}

Método destroy

Cuando se cierra una sesión, ya sea porque el gc (recolector de basura) lo pida o porque el usuario cierre su sesión (con session_destroy) se llamará a este método:

<?php
public function destroy($id_de_sesion)
{
    $sentencia = $this->base_de_datos->prepare("DELETE FROM sesiones WHERE id = ?;");
    return $sentencia->execute([$id_de_sesion]);
}

Lo que hacemos es eliminar el dato de nuestra base de datos. Por favor hay que observar que se llama a esta función con el id de sesión.

Método gc o garbage collector

PHP llama periódicamente a este método. Le pasa el tiempo de vida máximo que puede tener una sesión. Por ejemplo, si tiene 24 horas, un día (60 segundos * 60 minutos * 24 horas) nos llamará con el número 86400.

Con ese número y el tiempo actual hacemos una resta. Si hay sesiones cuyo último acceso haya sido antes de el resultado (por ejemplo, ayer) entonces las eliminamos. Esto es por seguridad, ya que no podemos dejar abierta una sesión por siempre.

<?php
public function gc($tiempo_de_vida)
{
    #Calculamos el tiempo actual menos el tiempo de vida.
    $caducidad = time() - $tiempo_de_vida;

    $sentencia = $this->base_de_datos->prepare("DELETE FROM sesiones WHERE ultimo_acceso < ?;");
    return $sentencia->execute([$caducidad]);
}

Y listo, con este método terminamos.

Ejemplo de uso

En el ejemplo más básico hacemos un include_once de la clase, la instanciamos, indicamos a PHP que utilice ese manejador y listo, todo correcto.

Veamos este código que inicia la sesión:

<?php
include_once "./ManejadorDeSesion.php";

$manejador = new ManejadorDeSesion();
session_set_save_handler($manejador);

session_start();

$_SESSION["nombre"] = "Luis";
?>

Como vemos, sólo las 3 primeras líneas son diferentes a lo que usualmente hacemos. Aparte de eso, llamamos a session_start como siempre. Y asignamos valores a $_SESSION.

Quiero que veamos una cosa importante, cuando iniciamos sesión (en el ejemplo de arriba) y luego vamos a nuestra base de datos, esto pasa:

Datos de sesión guardados gracias a nuestro manejador de sesiones

Como vemos, los datos han sido guardados. También vemos que han sido serializados de alguna forma, y en la columna de último acceso vemos un entero que representa el tiempo.

Para leer, hacemos esto:

<?php
include_once "./ManejadorDeSesion.php";

$manejador = new ManejadorDeSesion();
session_set_save_handler($manejador);

session_start();

echo $_SESSION["nombre"];
?>

Y para cerrar esto:

<?php
include_once "./ManejadorDeSesion.php";

$manejador = new ManejadorDeSesion();
session_set_save_handler($manejador);

session_start();
session_destroy();
?>

Las ventajas de todo esto es que tenemos el control casi total de las sesiones. Podemos cerrarlas haciendo una simple consulta, o cosas de esas.

El manejador ya está programado, es cuestión del desarrollador implementar bien los métodos y tal vez agregar algunas cosas 🙂

Por cierto, debemos registrar a nuestro manejador siempre, con estas líneas:

<?php
include_once "./ManejadorDeSesion.php";

$manejador = new ManejadorDeSesion();
session_set_save_handler($manejador);

Recomiendo tener otra clase que maneje las sesiones, así cada vez que lo llamemos se encargará de registrar el manejador, pero eso es otra historia que será contada en otro momento.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.
parzibyte

Programador freelancer listo para trabajar contigo. Aplicaciones web, móviles y de escritorio. PHP, Java, Go, Python, JavaScript, Kotlin y más :) https://parzibyte.me/blog/software-creado-por-parzibyte/

Ver comentarios

  • No quisiera ser grosero, asi que intentare ir al grano.
    ¿que ganas haciendo esto contra el hacer tu propio sistema de sesiones SIN sesiones?
    ya que vas a tener que llamar a la base de datos a cada paso que das, si simplemente almaceno la IP como id de la 'sesion' y con el control de tiempos e incluso navegador usado me sobra para mejorar todo lo que las sesiones te pueden ofrecer y gano en seguridad.
    Pero si quieres tienes razones para seguir con tu idea, por favor! convenceme

    • Hola. Por defecto las sesiones usan archivos, el problema aparece cuando se quiere modificar la sesión de manera concurrente. Los motores de bases de datos están pensados para manejar la concurrencia, por ello es que a veces se necesita usar una base de datos en lugar de los archivos.
      No veo la necesidad de convencerlo, me imagino que usted llegó a mi blog buscando en Google o algo así, ni siquiera lo conozco y no lo obligué ni invité a usar lo que aparece aquí. Usted puede usar lo que prefiera.
      Saludos!

Entradas recientes

Creador de credenciales web – Aplicación gratuita

Hoy te voy a presentar un creador de credenciales que acabo de programar y que…

1 semana hace

Desplegar PWA creada con Vue 3, Vite y SQLite3 en Apache

Ya te enseñé cómo convertir una aplicación web de Vue 3 en una PWA. Al…

2 semanas hace

Arquitectura para wasm con Go, Vue 3, Pinia y Vite

En este artículo voy a documentar la arquitectura que yo utilizo al trabajar con WebAssembly…

2 semanas hace

Vue 3 y Vite: crear PWA (Progressive Web App)

En un artículo anterior te enseñé a crear un PWA. Al final, cualquier aplicación que…

2 semanas hace

Errores de Comlink y algunas soluciones

Al usar Comlink para trabajar con los workers usando JavaScript me han aparecido algunos errores…

2 semanas hace

Esperar promesa para inicializar Store de Pinia con Vue 3

En este artículo te voy a enseñar cómo usar un "top level await" esperando a…

2 semanas hace

Esta web usa cookies.