Cómo implementar autenticación JWT en aplicaciones web / firebase-php-jwt

En el desarrollo de aplicaciones web es importante el tema de seguridad para proteger información y el uso de las funciones, una opción típica puede que sea la sesión, ideal para soluciones locales, pero no funcional en APIs para uso de terceros (Aplicaciones web, móviles, de escritorio o agentes de IA), para APIs existen los JWT, el artículo será abordado en PHP, a pesar de ello se tocarán características principales de esta técnica, sin más preambulo, comencemos.

Requerimientos

Antes de tener en cuenta los ejecutables requeridos, importante mencionar que el sistema operativo es Windows.

  • XAMPP
  • Composer

Puedes descargar el proyecto en github, solamente ten en cuenta cambiar rutas y url, ya que en los archivos empleo tunnels, además de que mi proyecto tiene un nombre diferente al del artículo:

https://github.com/domingofhdz/practicasAWOS

Qué es JWT

JSON Web Token, abreviado JWT, en una técnica que consiste en “encriptar” una autenticación mediante el servidor con una clave predefinida, al estructurar la sesión se pueden agregar, un tiempo de creación (iat), un tiempo de expiración (exp), información del usuario (sub) como id, nombre, tipo, dispositivo, ip, entre otros más que perfectamente pueden ser añadidos entre separadores o en un JSON codificado. Según el estandar JWT existen más parametros, pero los tres mencionados son los más básicos o al menos suficientes para este artículo.

¿Cuándo debemos implementar JWT? Como lo menciono en la introducción del artículo, JSON Web Token es útil cuando pasamos de un entorno local a uno donde varias u otras aplicaciones se conectan a una, ya sean web, móviles, de escritorio o agentes de IA.

JWT con firebase-php-jwt

firebase-php-jwt es una dependencia PHP que podemos descargar con composer, abrimos nuestra terminal de Windows, nos posicionamos en el proyecto, creamos una carpeta a la cual nombraremos firebase-php-jwt y nos posicionamos en esta carpeta (Cambia prueba por el nombre de tu proyecto):

cd c:/xampp/htdocs/prueba/firebase-php-jwt/

Después ejecutamos el siguiente comando:

composer require firebase/php-jwt

Si todo sale bien se descargarán los archivos requeridos de la dependencia firebase-php-jwt.

Comparto los códigos, pero antes menciono, que si ya en tu proyecto tienes tu propio login y tu propia estructura para API, solamente tomes como referencia las explicaciones y comentarios, en especial el manejo del JWT con JQuery y la obtención del header del lado del servidor que apreciarás adelante.

login.html es sencillamente el formulario para iniciar sesión, la página utiliza bootstrap por lo que añadí un modal para el mensaje de error.

c:/xampp/htdocs/prueba/login.html

<!doctype html>
<html lang="es">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Inicio de Sesión</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous" defer></script>
    <script src="js/login.js" defer></script>
</head>
<body>
    <div class="container">

        <div class="row">
            <div class="col-1 col-sm-1 col-md-2 col-lg-3 col-xl-3 col-xxl-3">
            </div>
            <div class="col-10 col-sm-10 col-md-8 col-lg-6 col-xl-6 col-xxl-6">
                <form id="frmLogin" method="POST">
                    <div class="mb-1">
                        <label for="txtUsuario">Usuario:</label>
                        <input type="text" id="txtUsuario" name="txtUsuario" class="form-control">
                    </div>
                    <div class="mb-1">
                        <label for="txtContrasena">Contraseña:</label>
                        <input type="password" id="txtContrasena" name="txtContrasena" class="form-control">
                    </div>
                    <div class="mb-1 text-end">
                        <button type="submit" class="btn btn-primary">Iniciar Sesión</button>
                        <button type="reset" class="btn btn-secondary">Cancelar</button>
                    </div>
                </form>
            </div>
            <div class="col-1 col-sm-1 col-md-2 col-lg-3 col-xl-3 col-xxl-3">
            </div>
        </div>

    </div>

    <!-- Modal -->
    <div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
            <div class="modal-header">
                <h1 class="modal-title fs-5" id="exampleModalLabel">Error</h1>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <div class="modal-body">
                Usuario o Contraseña incorrecto(s)
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Aceptar</button>
            </div>
            </div>
        </div>
    </div>
</body>
</html>

login.js es el script que envía una petición para revisar el estado de la sesión y añade el evento de envio al formulario para prevenir la recarga de la página, enviar los datos en una petición donde si no nos regresa una respuesta con el texto que diga error significa que la información es correcta. En los comentarios del código hago la mención de cambiar las rutas y URLs.

c:/xampp/htdocs/prueba/js/login.js

// La URL de la API y endpoints cambialos según el tunnel, hosting, local o ubicación del proyecto de la aplicación web
// URL de la API
const API = "https://carefully-eclipse-refresh-aircraft.trycloudflare.com/api-github/practicasAWOS"

const modalErrorLogin = new bootstrap.Modal("#exampleModal", {
    keyboard: false
})

// Añade a toda petición que se realice, el header que contiene el JWT, obtenido de un almacenamiento muy persistente
$.ajaxSetup({
    headers: {
        Authorization: `Bearer ${localStorage.getItem("jwt")}`
    }
})

// Endpoint combinado con la API para comprobar si se inició sesión
$.get(`${API}/servicio.php?sesion`, function (sesion) {
    if (sesion.length) {
        // Si inició sesión

        return
    }

    // Si no inició sesión
    // Podrías añadir un redireccionamiento si lo crees prudente
})

$("#frmLogin").submit(function (event) {
    event.preventDefault()

    // Endpoint combinado con la API para iniciar sesión
    $.post(`${API}/servicio.php?iniciarSesion`, $(this).serialize(), function (respuesta) {
        if (respuesta == "error") {
            modalErrorLogin.show()
            return
        }

        // Guarda el JWT en un almacenamiento persistente
        localStorage.setItem("jwt", respuesta)
        // Cambia el redireccionamiento según tu aplicación web
        window.location = "index.html"
    })
})

servicio.php es la API donde se realizará la autenticación, consulta de estado de sesión y uso de funciones, añadí comentarios en puntos suspensivos para solo prestar atención al código principal, además de complementar de otros comentarios en loas partes importantes.

c:/xampp/htdocs/prueba/servicio.php

<?php

ini_set("display_errors", 1);
ini_set("display_startup_errors", 1);
error_reporting(E_ALL & ~E_DEPRECATED);

header("Cache-Control: no-cache, must-revalidate");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Headers: Authorization, X-API-KEY, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Allow: GET, POST, OPTIONS");

if ($_SERVER["REQUEST_METHOD"] == "OPTIONS") {
  http_response_code(200);
  exit;
}

if (isset($_GET["PING"])) {
  exit;
}

date_default_timezone_set("America/Matamoros");

if (isset($_GET["DATETIME"])) {
  echo date("Y-m-d H:i:s");
  exit;
}

# ...

require "firebase-php-jwt/vendor/autoload.php";

$headers = getallheaders();

$token = "";
if (isset($headers["Authorization"])) {
  $token = str_replace("Bearer ", "", $headers["Authorization"]);
}

try {
  # el segundo parametro es la clave para codificar y decodificar el JWT
  # debe ser una string no corta, por eso rellené de guiones
  $decoded = Firebase\JWT\JWT::decode($token, new Firebase\JWT\Key("Test12345-----------------------------------------------", "HS256"));

  # $usuario puede ser usada para validaciones
  $usuario = explode("/", $decoded->sub);
  $id      = $usuario[0];
  $usr     = $usuario[1];
  $tipo    = $usuario[2];

  # $login puede ser usada para validaciones
  $login = true;
}
catch (Exception $error) {
  $usuario = array();
  $login   = false;
}

# endpoint para revisar estado de la sesión
if (isset($_GET["sesion"])) {
  header("Content-Type: application/json");
  echo json_encode($usuario);
}
# endpoint para iniciar sesión
elseif (isset($_GET["iniciarSesion"])) {
  $select = $con->select("usuarios");
  $select->where("usuario", "=", $_POST["txtUsuario"]);
  $select->where_and("contrasena", "=", $_POST["txtContrasena"]);

  $usuarios = $select->execute();

  if (count($usuarios)) {
    $usuario = $usuarios[0];

    $payload = [
      "iat" => time(),
      "exp" => time() + (60 * 60 * 24 * 7),
      "sub" => $usuario["idUsuario"] . "/" . $usuario["usuario"] . "/" . $usuario["tipo"]
    ];
    # el segundo parametro es la clave para codificar y decodificar el JWT
    # debe ser una string no corta, por eso rellené de guiones
    $jwt = Firebase\JWT\JWT::encode($payload, "Test12345-----------------------------------------------", "HS256");

    echo $jwt;
  }
  else {
    echo "error";
  }
}
# en cada endpoint podemos hacer uso de la variable login para seguridad
# tenemos la variable login y usuario para realizar más validaciones
elseif (isset($_GET["productos"]) && $login) {
  # ...
}
elseif (isset($_GET["editarProducto"]) && $login) {
  # ...
}
elseif (isset($_GET["categoriasCombo"]) && $login) {
  # ...
}
elseif (isset($_GET["eliminarProducto"]) && $login) {
  # ...
}
elseif (isset($_GET["agregarProducto"]) && $login) {
  # ...
}
elseif (isset($_GET["modificarProducto"]) && $login) {
  # ...
}

?>

Para “cerrar la sesión” solamente añadimos esta línea de código en el botón correspondiente.

localStorage.removeItem("jwt")

Detalle importante a tener en cuenta, puede quedarse almacenado en cache el JWT, por ejemplo al cerrar sesión y al hacer un back history parece conservarse, en algunos casos no sucede nada y en casos específicos persiste, para evitar eso podemos añadir la siguiente solución.

window.addEventListener("pageshow", function(event) {
    if (event.persisted) {
        if (!localStorage.getItem("jwt")) {
            window.location = "login.html"
        }
        else {
            location.reload()
        }
    }
})

Conclusión

El artículo ha sido breve, sin embargo toca la parte lógica básica para comprender los JWT, descarga de la librería firebase-php-jwt, configuración de peticiones con JQuery, obtención del token de lado del cliente y servidor así como su codificación y decodificación. Espero este post les sea de utilidad y estaré al tanto de cualquier comentario, gracias por su tiempo.

Comments

No comments yet. Why don’t you start the discussion?

Deja tus comentarios