11_2 - Sistema login II: Tokens

Una vez sabemos como hacer un sistema de login, el siguiente paso es comprobar que el usuario está logueado cuando realiza peticiones a rutas que queremos proteger.

Tradicionalmente las aplicaciones web han usado sesiones y cookies para hacer un seguimiento de los usuarios que han iniciado sesión. Cuando el usuario se logueaba correctamente, se iniciaba una sesión en el servidor, y se enviaba el id de esa sesión para que el cliente lo guardara en el navegador (normalmente en una cookie). De esta forma, no necesitan enviar sus credenciales en cada petición. Enviando el id de la sesión el servidor podía saber si el usuarios estaba logueado o no mirando si tenía alguna sesión abierta con ese id.

Si quieres saber más sobres sesiones, puedes consultar este enlace donde encontrarás diferentes funciones se sesión (iniciar una sesión, eliminarla, obtener su id…).

Este enfoque funciona muy bien con páginas web. El problema es que cuando desarrollamos una API nuestro cliente no tiene porqué ser una web. Si, por ejemplo, estamos enviando los datos a una aplicación de escritorio, ésta no puede utilizar cookies para almacenar el id de la sesión.

Para tratar de solucionar este problema, existe otro enfoque que es la autenticación basada en tokens. A diferencia de la anterior, donde se mantenía una sesión iniciada (stateful, mantiene el estado), la autenticación basada en tokens no mantiene ningún estado ni sesión en el servidor (stateless).

La idea de este enfoque es muy sencilla: cuando el usuario manda sus credenciales, el servidor las comprueba y, si son correctas, genera una cadena de texto aleatoria (más adelante veremos como podemos hacer que esta cadena no sea tan aleatoria y guarde cierta información que queramos). Esta cadena es lo que se denomina token. Ese token lo guardamos en la bbdd asociado al cliente que ha iniciado sesión y se lo enviamos en la respuesta del servidor.

A partir de ahí, cuando el cliente haga peticiones a nuestros endpoints, agregará el token en la cabecera de la petición. El servidor, comprobará si ese token es válido buscando en la bbdd el usuario asociado con ese token.

Vamos a hacer una prueba del funcionamiento de los tokens. Nuestra tabla tendrá el siguiente aspecto:

  • id: Identificador del usuario que actuará como clave principal. Será un entero autoincrementativo.
  • login: Nombre de usuario. Varchar de 60 caracteres.
  • password: Contraseña del usuario. Varchar de 255 caracteres (más adelante veremos el porque de hacerlo tan largo).
  • token: Campo para almacenar los tokens de los usuarios. Varchar de 255 caracteres.

Crearemos dos usuarios cualesquiera (por ejemplo, profesor con password profesor y alumno con password alumno) e implementaremos un sistema de login muy sencillo (por ahora, nos vamos a olvidar de arquitecturas). Para crear los tokens, utilizaremos la función rand de PHP que genera un número aleatorio y el método password_hash visto en el tema anterior (obviamente, esta no es la mejor manera de generar tokens, pero, por ahora, vamos a simplificar el proceso).

Por último, si el usuario envía las credenciales correctas, almacenaremos el token en la bbdd y lo mostraremos por pantalla:

include "conexion.php";


$json = file_get_contents('php://input');
$user = json_decode($json);

$usuario = $user->usuario;
$password = $user->password;

$sql = "SELECT id, password FROM usuarios WHERE login=:usuario LIMIT 1";

$sth = $pdo->prepare($sql);
$sth->bindParam(':usuario', $usuario, PDO::PARAM_STR);
$sth->execute();

if ($result = $sth->fetch(PDO::FETCH_NUM)) {
    if(password_verify($password, $result[1])) {
        $str = rand();
        $str2 = rand();
        $access_token = password_hash($str, PASSWORD_DEFAULT, ['cost' => 15]);
    
        $sql = "UPDATE usuarios SET token = :token WHERE id = $result[0]";
        $sth = $pdo->prepare($sql);
        $sth->bindParam(':token', $acces_token, PDO::PARAM_STR);
        $sth->execute();
    
        $data = [
            "Token de acceso: " => $access_token
        ];
        response($data, 200);
    } else {
        $data = [
            "Error: " => "Credenciales inválidas"
        ];
        response($data, 401);
    }    
} else {
    $data = [
        "Error: " => "Credenciales inválidas"
    ];
    response($data, 401);    
}


function response ($data, $status_code) {
    http_response_code($status_code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data);
    exit(0);
}

Para comprobar el token, el usuario tiene que enviarlo en la cabecera, en formato bearer (es el estándar de autorización OAuth 2.0). Por ejemplo, podemos utilizar la extensión RapidAPI de VSC para hacer peticiones y añadirle el token en la cabecera (o podríamos utilizar cualquier herramienta que nos permita hacer peticiones a nuestras APIs, como PostMan):

Para leer el token, usaremos la función getallheaders de PHP, la cual nos devuelve un array con todos los campos de la cabecera de la petición. Tenemos que leer el campo Authorization:

var_dump(getallheaders()['Authorization']);

Con esto, ya tenemos todo listo para comprobar el token que nos envíe el usuario. Vamos a crear un archivo PHP (acceso.php) que comprobará el token que nos envíen en la cabecera y devolverá los datos del usuario:

include "conexion.php";


$token = ltrim(getallheaders()['Authorization'], 'Bearer ');


if($token == "") {
    $data = [
        "Error" => "Token no encontrado"
    ];
    response($data, 401);
}

$sql = "SELECT * FROM usuarios WHERE token=:token LIMIT 1";

$sth = $pdo->prepare($sql);
$sth->bindParam(':token', $token, PDO::PARAM_STR);
$sth->execute();

$result = $sth->fetch(PDO::FETCH_ASSOC);

if ($result) {
    response($result, 200);
} else {
    $data = ([
        "Error: " => "Token inválido"
    ]);
    response($data, 401);
}

function response ($data, $status_code) {
    http_response_code($status_code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data);
    exit(0);
}

Fíjate que eliminamos la cadena Bearer del principio del token con ltrim().

Listo, ya podemos comprobar el token que nos envía el usuario y saber quién es haciendo una simple consulta en la bbdd.

Si dejamos nuestro sistema de tokens como está, los usuarios se validarían sólo una vez en nuestra API y el token generado sería valido indefinidamente (lo cuál no es buena idea por motivos de seguridad). Lo normal, es poner un tiempo límite, con lo que si se supera ese tiempo el token deja de ser válido.

Vamos a crear una nueva tabla donde almacenaremos los tokens de los usuarios (podemos borrar el campo token de la tabla usuarios). La tabla tendrá 3 campos:

  • id_usuario: Id del usuario. Será la clave principal de la tabla, con lo que no podrá haber tokens diferentes para un mismo usuario.
  • token: Token del usuario que se creará cuando inicie sesión correctamente.
  • expires_in: Tiempo de expiración del token. Para evitarnos problemas, usaremos formato Unix timestamp.

Para el campo expires_in, vamos a utilizar un par de funciones de PHP que nos van a venir muy bien (en realidad, hay varias formas de trabajar con fechas):

  • strtotime: Convierte una descripción de fecha/hora textual en Inglés a una fecha Unix.
  • date_default_timezone_set: Establecemos nuestra zona horaria (Europe/Madrid). Esto no sería realmente necesario, pero por motivos de visualización lo usaremos en nuestras funciones.
  • date: Para mostrar la fecha con el formato que queramos. Le podemos pasar un timestamp y nos mostrará la fecha correspondiente. Ésto, de nuevo, no es necesario y no lo vamos a utilizar en la versión final. Lo usaremos solo para comprobar que la aplicación funciona correctamente.

Por ejemplo, mira el siguiente código:

date_default_timezone_set('Europe/Madrid');

$date = strtotime("now + 600 seconds");

echo $date, "\n\n";

echo date('d-m-Y H:i:s', $date);

Si lo ejecutas, verás el timestamp generado y la fecha correspondiente a ese timestamp. Fíjate que a la hora de establecer la fecha, usamos la cadena “now + 600 seconds” para sumar 10 minutos a la hora local.

Vamos a hacer los cambios necesarios en login.php para crear el token con tiempo de expiración y almacenarlo en la tabla correspondiente:

date_default_timezone_set('Europe/Madrid');

include "conexion.php";


$json = file_get_contents('php://input');
$user = json_decode($json);

$usuario = $user->usuario;
$password = $user->password;

$sql = "SELECT id, password FROM usuarios WHERE login=:usuario LIMIT 1";

$sth = $pdo->prepare($sql);
$sth->bindParam(':usuario', $usuario, PDO::PARAM_STR);
$sth->execute();

if ($result = $sth->fetch(PDO::FETCH_NUM)) {
    if(password_verify($password, $result[1])) {
        $str = rand();
        $access_token = password_hash($str, PASSWORD_DEFAULT, ['cost' => 15]);
        $access_token_expires_in = strtotime("now + 600 seconds");
    
        setToken($pdo, $result[0], $access_token, "tokens", $access_token_expires_in);
        
        $data = [
            "Token de acceso: " => $access_token
        ];
        response($data, 200);    
    } else {
        $data = [
            "Error: " => "Credenciales inválidas"
        ];
        response($data, 401);
    }    
} else {
    $data = [
        "Error: " => "Credenciales inválidas"
    ];
    response($data, 401);    
}


function response ($data, $status_code) {
    http_response_code($status_code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data);
    exit(0);
}

function setToken($pdo, $id_usuario, $token, $table, $expires_in, $active = null) {
    if ($table == "tokens") {
        $sql = "INSERT INTO tokens (id_usuario, token, expires_in) VALUES (:id_usuario, :token, :expires_in) 
            ON DUPLICATE KEY UPDATE token = :token, expires_in = :expires_in";
    }

    $sth = $pdo->prepare($sql);
    $sth->bindParam(':id_usuario', $id_usuario, PDO::PARAM_INT);
    $sth->bindParam(':token', $token, PDO::PARAM_STR);
    $sth->bindParam(':expires_in', $expires_in, PDO::PARAM_INT);
    return $sth->execute();    
}

Ahora, en nuestro script donde comprobamos el token, podemos saber si el es válido y si no ha expirado:

date_default_timezone_set('Europe/Madrid');

include "conexion.php";

$token = ltrim(getallheaders()['Authorization'], 'Bearer ');

$sql = "SELECT * FROM tokens WHERE token = :token LIMIT 1";
$sth = $pdo->prepare($sql);
$sth->bindParam(':token', $token, PDO::PARAM_STR);
$sth->execute();

$result = $sth->fetch(PDO::FETCH_ASSOC);

if ($result) {
    $now = strtotime("now");
    if ($now < $result["expires_in"]) {
        response($result, 200);
    } else {
        $data = ([
            "Error: " => "Token expirado"
        ]);
        response($data, 401);
        }
} else {
    $data = ([
        "Error: " => "Token inválido"
    ]);
    response($data, 401);
}

function response ($data, $status_code) {
    http_response_code($status_code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data);
    exit(0);
}

Fíjate que en el último archivo volvemos a usar la función date_default_timezone_set para comparar ambas fechas en la misma franja horaria. Podríamos no usarla ni aquí ni a la hora de generar el token con el mismo resultado, pero si queremos visualizar la fecha en algún momento nos mostraría la fecha con UTC, no UTC + 1 que usamos en España.
No existe ningún tiempo de expiración mejor que otro para los tokens. Dependiendo de la aplicación y la información a la que el usuario quiera acceder, pondremos un tiempo más largo o más corto.

Nuestro sistema de tokens funciona perfectamente, pero existe un problema. Si el token del usuario sobrepasa el tiempo de expiración, ¿debe volver a iniciar sesión en nuestra aplicación? Si lo dejamos como está, esto podría estropear la experiencia del usuario, ya que le estaríamos pidiendo loguearse cada poco tiempo (según el tiempo de expiración del token), incluso a mitad del uso de nuestra aplicación.

Para solucionarlo, tenemos otro tipo de tokens llamados tokens de refesco.

Estos tokens se utilizan para volver a pedir un token de acceso nuevo cuando al antiguo ha caducado de forma transparente al usuario. Si la aplicación detecta que el token de acceso ha expirado, se lo comunicará al cliente, y éste enviará el token de refesco para volver a pedir uno nuevo de forma automática.

Vamos a probar su funcionamiento. Lo primero será crear una nueva tabla llamada tokens_refresco (más adelante entenderás porqué separamos ambos tokens). La tabla tendrá los siguientes campos:

  • id: Clave principal de la tabla. Entero autoincrementativo.
  • id_usuario: Id del usuario al que corresponde el token de refresco. De nuevo, más adelante entenderás porqué, en este caso, no nos interesa utilizarlo como clave principal (al contrario que la anterior tabla de tokens).
  • token: El token de refesco.
  • expires_in: Igual que con los tokens de acceso, estos tokens también tendrán un tiempo de expiración (aunque mucho más largo).
  • activo: Campo que indicará si el token está activo o no (Como antes, ya entenderás después porqué es necesario y para qué lo vamos a utilizar). Será de tipo booleano.

Modificamos nuestro login para añadir el nuevo token a la nueva tabla:

date_default_timezone_set('Europe/Madrid');

include "conexion.php";


$json = file_get_contents('php://input');
$user = json_decode($json);

$usuario = $user->usuario;
$password = $user->password;

$sql = "SELECT id, password FROM usuarios WHERE login=:usuario LIMIT 1";

$sth = $pdo->prepare($sql);
$sth->bindParam(':usuario', $usuario, PDO::PARAM_STR);
$sth->execute();

if ($result = $sth->fetch(PDO::FETCH_NUM)) {
    if(password_verify($password, $result[1])) {
        $str = rand();
        $str2 = rand();
        $access_token = password_hash($str, PASSWORD_DEFAULT, ['cost' => 15]);
        $access_token_expires_in = strtotime("now + 600 seconds");
        $refresh_token = password_hash($str2, PASSWORD_DEFAULT, ['cost' => 15]);
        $refresh_token_expires_in = strtotime("now + 5 days");
    
        setToken($pdo, $result[0], $access_token, "tokens", $access_token_expires_in);
        setToken($pdo, $result[0], $refresh_token, "tokens_refresco", $refresh_token_expires_in, true);        
    
        $data = [
            "Token de acceso: " => $access_token,
            "Token de refresco: " => $refresh_token
        ];
        response($data, 200);    
    } else {
        $data = [
            "Error: " => "Credenciales inválidas"
        ];
        response($data, 401);
    }    
} else {
    $data = [
        "Error: " => "Credenciales inválidas"
    ];
    response($data, 401);    
}


function response ($data, $status_code) {
    http_response_code($status_code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data);
    exit(0);
}

function setToken($pdo, $id_usuario, $token, $table, $expires_in, $active = null) {
    if ($table == "tokens") {
        $sql = "INSERT INTO tokens (id_usuario, token, expires_in) VALUES (:id_usuario, :token, :expires_in) 
            ON DUPLICATE KEY UPDATE token = :token, expires_in = :expires_in";
    } else {
        $sql = "INSERT INTO tokens_refresco (id_usuario, token, expires_in, activo) VALUES (:id_usuario, :token, :expires_in, true)";
    }

    $sth = $pdo->prepare($sql);
    $sth->bindParam(':id_usuario', $id_usuario, PDO::PARAM_INT);
    $sth->bindParam(':token', $token, PDO::PARAM_STR);
    $sth->bindParam(':expires_in', $expires_in, PDO::PARAM_INT);
    return $sth->execute();    
}

Ahora, cada vez que un usuario inicie sesión en nuestra API, recibirá dos tokens: el de acceso y el de refresco, y almacenaremos ambos en sus tablas correspondientes.

El token de acceso funciona igual que antes (no hay que tocar nada). La diferencia es que, cuando el servidor detecta que éste ha expirado, devolverá un mensaje con status code 401. El cliente, comprobará si tiene un token de refresco y se lo enviará al servidor para refrescar el token de acceso.

De nuevo, no hay un tiempo de vida ideal de los tokens de refresco. En el ejemplo, hemos usado 5 días como timpo de expiración, pero podríamos haber usado 1 mes, 1 año, 5 años… incluso tiempo ilimitado. Aunque pueda parecer un fallo de seguridad (ya que nos pueden robar ese token y tener acceso durante un tiempo largo a los recursos en nuestro nombre), veremos en el siguiente punto como solventar esa situación.

En cuanto al token de refresco, cuando se utilice el servidor volverá a enviar un par de tokens (ambos nuevos, el de acceso y el de refresco) e invalidará los tokens de refresco de ese usuario poniendo su campo activo a false. A partir de ese momento, el usuario podrá seguir haciendo peticiones a nuestra API con el nuevo token de acceso (y usar, de nuevo, el token de refresco cuando éste expire).

Aunque el sistema de tokens funciona a la perfección, tenemos que tener en cuenta varias cosas para añadirle seguridad. Como hemos dicho antes, tenemos el campo activo en la tabla tokens_refresco y siempre lo ponemos a true. Si lo dejáramos así, un usuario podría tener varios tokens de refresco activos, con lo que cuando inicia sesión tenemos que desactivar todos sus tokens de refresco antes de crear el nuevo (poner el campo activo = false en la tabla para los tokens del usuario).

Además, ¿qué pasaría si alguien robara alguno de los tokens?. Obviamente, la comunicación entre el servidor y el cliente debería ser seguro, mediante el protocolo https para evitar que alguien intercepte los tokens.

Pero existe otro problema. Ten en cuenta que los tokens se almacenan, normalmente, en el localstorage o sessionstorage del navegador, es decir, en el lado del cliente. Alguien puede acceder a alguno de esos almacenamientos y robar uno o los dos tokens. Si roba el token de acceso, no habría demasiados problemas (relativamente). Las acciones del atacante estarían limitadas al tiempo de vida del token. En cuanto éste expirara, al no tener un token de refresco válido, ya no podría hacerse pasar por nosotros.

Pero, ¿qué pasa si el token que se roba es el de refresco? En este caso, si no tomamos medidas, el atacante podría tener acceso ilimitado, ya que iría refrescando los tokens de acceso. Para evitarlo, lo que se suele hacer es desactivar todos los tokens del usuario (el de refresco y el de acceso) si alguien intenta refrescar el token de acceso con un token de refresco antiguo. De ahí que almacenemos los tokens antiguos en la tabla (aunque invalidados con el campo activo), para detectar si alguien intenta utilizar uno de los tokens anteriores.

Por ejemplo, supongamos que un atacante roba el token de refresco. Pueden suceder dos situaciones:

  • El atacante pide un token de acceso con el token de refresco válido que ha robado. El servidor, como hemos dicho al principio, desactiva todos los tokens de refresco de ese usuario antes de generar el nuevo (el servidor no tiene ninguna manera de saber si el usuario es legítimo o no). Cuando el usuario legítimo intente obtener otro token de acceso con un token de refresco desactivado, el servidor detectará que el token es válido, pero está desactivado (alguien más ha usado ese token para pedir un token de acceso nuevo). En ese momento, el servidor invalida todos los tokens (tanto el del acceso como el de refresco) de ese usuario. A partir de ahí, si alguien quiere utilizar los recursos protegidos de nuestra API, tendrá que volver a iniciar sesión.
  • Si es el usuario legítimo el primero que pide un token nuevo de acceso con el token de refresco, se repetirá la situación anterior en caso que el atacante utilice el antiguo (desactivar todos los tokens de ese usuario). Ten en cuenta que, como hemos dicho, el servidor no tiene ninguna manera de saber cuál es el usuario legítimo y cuál el atacante.

La razón de almacenar los tokens antiguos, es poder diferenciar cuando alguien utiliza un token válido pero desactivado a cuando alguien utiliza un token inválido (no existe en nuestra base de datos, con lo que no damos acceso, pero sin realizar otras acciones).

Una cosa de la que no hemos hablado es la posibilidad de invalidar usuarios. Si queremos, por ejemplo, poder denegar el acceso a usuarios individuales, podemos añadir un campo a la tabla usuarios que indique esta situación (similar al campo activo de la tabla tokens_refresco).

Ejercicio 1

Implementa la autenticación basada en tokens en nuestra API de películas

Ejercicio 2

Crea un sistema de roles de usuarios, añadiendo una nueva tabla con los diferentes roles que pueden tener los usuarios. Implementa un sistema para invalidar usuarios individuales o roles completos.

Ejercicio 3

Comprueba que tu sistema invalida los tokens de un usuario cuando se utilizan varios tokens de refresco diferentes.

  • clase/daw/dws/2eval/tokens.txt
  • Última modificación: 2025/09/16 11:41
  • por cesguiro