Crea una API Básica en PHP con Autenticación a través de token

Camilo Herrera
winkhostingla
Published in
12 min readJan 11, 2023
Photo by Mark Fletcher-Brown on Unsplash

Hoy vamos a crear en el menor tiempo posible una API con PHP y autenticación a través de tokens. Ven!, camina conmigo por el maravilloso mundo del scripting, del hack y el slash.

Tip motivacional: Recuerda que la programación es más que ingeniería, es creatividad, es habilidad. Serás mejor cada día siempre y cuando sigas practicando y resolviendo problemas con tu código.

Vamos a iniciar con asumir que estás usando apache http server y php 8.x (obviamente en una cuenta de hosting en Winkhosting.co), ó que ya tienes un entorno configurado en otro proveedor o tu PC y todo funciona correctamente (si usas otro servidor web como nginx o caddy tendrás que buscar las opciones para usar sobre escritura de rutas.)

Para continuar, ya teniendo presente que tienes un entorno donde puedas trabajar, nuestra API va requerir el manejo de rutas o “endpoints”, vamos a iniciar por crear la estructura y funcionalidad de manejo de rutas.

Enrutamiento, mod_rewrite y .htaccess

Inicia por crear un directorio, por ejemplo, en mi caso he creado el directorio /simple-api

En el directorio crea un archivo con nombre .htaccess y en él escribe/pega las siguientes líneas:

<Files "*.php">
Require ip 127.0.0.1
</Files>

<Files "index.php">
Require all granted
</Files>

RewriteEngine On
#Quita los comentarios para forzar https, debes tener un certificado SSL.
#RewriteCond %{HTTPS} off
#RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L]

#Activa el HTTP Authorization Header si usas php-cgi y puedes usar normalmente
# $_SERVER["HTTP_AUTHORIZATION"]
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]

RewriteBase /simple-api/
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ index.php [QSA,L]

Vamos a recorrer las secciones del archivo para explicar lo que hace cada una:

Restricciones de acceso a scripts PHP

<Files "*.php">
Require ip 127.0.0.1
</Files>

<Files "index.php">
Require all granted
</Files>

Estas secciones las uso por seguridad, lo que indican es que el único archivo .php al que pueden hacerse peticiones o “acceder” es index.php quien será encargado de administrar las rutas a los diferentes endpoints de nuestra API (si desconoces el concepto de endpoint, puedes consultarlo aquí)

En resumen, lo que le indicamos a Apache http server es:

  • Impide el acceso a archivos con extensión .php a todos los orígenes excepto 127.0.0.1, la dirección de localhost en IPv4, si tu servidor ya utiliza IPv6 tendrías que agregar la IP ::1. Es decir que solo peticiones locales pueden hacerse a scripts en el directorio.
  • Autoriza las consultas y peticiones desde cualquier origen (IPs de origen) al archivo index.php

Forzar la redirección del tráfico a HTTPS (Con un certificado SSL)

Continuamos con las reglas para utilizar https en tu API, esto sería lo recomendado en producción, en pruebas puedes usar http sin problemas.

RewriteEngine On
#Quita los comentarios para forzar https, debes tener un certificado SSL.
#RewriteCond %{HTTPS} off
#RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L]

La primera línea del archivo activa el motor de escritura de rutas con el valor “On”, las siguientes líneas son las encargadas de modificar el tráfico y pasar de una URL que inicie en http por su correspondiente https. No voy a entrar en detalles sobre condiciones de redirección, este tema ha tomado libros enteros, si quieres saber más, consulta la documentación de Apache aquí.

Advertencia: Normalmente mod_rewrite está activado por defecto en los servidores, si recibes algún tipo de error asociado a la ausencia de este módulo tendrías que solicitar soporte a tu proveedor de hosting para activarla o activarla en tu entorno según corresponda.

HTTP Authorization Header y php-cgi

#Activa el HTTP Authorization Header si usas php-cgi y puedes usar normalmente 
# $_SERVER["HTTP_AUTHORIZATION"]
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]

Estas líneas son opcionales si no usas php como cgi (php-cgi), mi recomendación desde el fondo de mi corazón es que las uses en cualquier caso a menos que te genere algún tipo de error. Lo que permiten estás líneas es la activación y guardado de la información de autenticación en el arreglo $_SERVER del PHP, en el item con llave “HTTP_AUTHORIZATION”.

Si planeas usar autenticación básica a través de headers, este sería un requisito para que PHP reciba la información necesaria.

Escritura de rutas, ruta base y archivo index.php

RewriteBase /simple-api/
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ index.php [QSA,L]

Estás líneas finales hacen la magia de enviar cualquier ruta dentro de /simple-api/ al archivo index.php, la información de la ruta será procesada por este archivo y lo veremos más adelante.

Al fin!, nuestro archivo .htaccess ha sido creado y estamos listos para continuar por ese camino lleno de alegría de la programación con PHP.

Photo by Catalin Pop on Unsplash

Si, así deberías sentirte, como en esa imagen cada vez que abres tu editor de código favorito.

Procesamiento de rutas

Vamos a iniciar por crear un archivo index.php, dentro del mismo directorio /simple-api, luego abre el tag de php tal como te enseñaron en la escuela, nada de tags cortos, no somos programadores vulgares:

<?php

Pro tip: No uses el tag de cierre de php a menos que sea absolutamente para el contexto de uso de tu script. Agradéceme luego.

Vamos a definir algunas variables iniciales para manejar nuestros endpoints, la ruta base y los parámetros recibidos en las peticiones:

<?php

$BASE_URI = "/simple-api/";

$endpoints = array();
$requestData = array();

$parsedURI = parse_url($_SERVER["REQUEST_URI"]);
$endpointName = str_replace($BASE_URI, "", $parsedURI["path"]);

if (empty($endpointName)) {
$endpointName = "/";
}

BASE_URI corresponde a la ruta base de nuestro directorio, toda petición contendrá este texto al inicio, es la misma que usamos en el archivo .htaccess.

$endPoints contendrá funciones anónimas (closures) con la lógica para procesar una petición específica. Usamos closures para reducir la complejidad y cantidad de código en este ejemplo, pero se recomienda usar una clase o separar la lógica de cada endpoint del manejo de las rutas.

$endpointName es el nombre del endpoint que fue consultado en la petición, se determina a partir del “REQUEST_URI” en el arreglo $_SERVER de PHP, primero se analiza la cadena de texto usando parse_url() y se extrae el path (ruta) y la cadena query con parámetros get si aplican para la petición, a continuación se hace un remplazo de texto para quitar la cadena de la ruta base del URI, de esta forma obtenemos solo la ruta del endpoint. Si esta variable resulta vacía, entenderemos que intentan ingresar a la ruta base de la API y definimos un nombre de endpoint por defecto “/”, este nos servirá más adelante para detectar este escenario y responder de forma adecuada.

Ahora vamos a definir la lógica tres endpoints:

  • Endpoint de ruta base, es decir, cuando el usuario intenta consultar /simple-api/ directamente sin usar un endpoint específico.
  • Endpoint “sayhello” (di hola), este endpoint recibirá un parámetro “name” (nombre) y retornará una respuesta con el texto “hello! <nombre>”
  • Endpoint “404”, este no se usará directamente, servirá para responder a cualquier otro nombre de endpoint que no exista en la API con un mensaje por defecto.
// closures para definir la lógica de cada endpoint, 
// lo sé, esto se puede mejorar con un esquema OOP pero es un ejemplo básico,
// no hagan esto en casa!, bueno o si quieren háganlo, no se sientan juzgados.

/**
* imprime un mensaje por defecto si se consulta la ruta base de la API.
* @param array $requestData contiene los parámetros enviados en la solicitud, para este endpoint son ignorados.
* @return void
*/
$endpoints["/"] = function (array $requestData): void {

echo json_encode("Bienvenido a mi API!");
};

/**
* imprime un mensaje de saludo con el nombre indicado en el item $requestData["name"]
* si la variable está vacía se usa un nombre por defecto.
* @param array $requestData este arreglo debe contener un item con llave "name" si quieres mostrar
* un nombre personalizado en el saludo.
* @return void
*/
$endpoints["sayhello"] = function (array $requestData): void {

if (!isset($requestData["name"])) {
$requestData["name"] = "Misterioso enmascarado";
}

echo json_encode("hello! " . $requestData["name"]);
};

/**
* imprime un mensaje por defecto si la ruta del endpoint no existe.
* @param array $requestData contiene los parámetros enviados en la solicitud, para este endpoint son ignorados.
* @return void
*/
$endpoints["404"] = function ($requestData): void {

echo json_encode("El endpoint " . $requestData["endpointName"] . " no fue encontrado.");
};

Como puedes ver en la sección de código, definimos y guardamos las funciones encargadas de cada ruta en el arreglo $endpoints, esto aprovechando la funcionalidad de “closures” (funciones anónimas) en php. Estos closures deben apegarse a una convención de firma de la función, la cual recibe un arreglo $requestData y no tiene retorno (void). En el arreglo $requestData se envían los parámetros recibidos por el endpoint para ser procesados.

Continuamos. Ahora vamos con la codificación de la respuesta de nuestra API, en este caso y por gusto, es decir, mi gusto mi querido lector, voy a imponerte la codificación en formato JSON y UTF-8 así:

//definimos el encoding de la respuesta, por defecto usaremos json
header("Content-Type: application/json; charset=UTF-8");

De esta forma toda salida de información al navegador será codificada con JSON y caracteres UTF-8, recomiendo evitar juegos de caracteres diferentes y mantener siempre el estándar, se puede convertir en un infierno rápidamente si decides usar otros tipos.

A continuación veremos el lugar donde ocurre la magia de la API:

if (isset($endpoints[$endpointName])) {
$endpoints[$endpointName]($requestData);
} else {
$endpoints["404"](array("endpointName" => $endpointName));
}

Al almacenar los endpoints en un arreglo podemos aprovechar la función isset() de php para determinar si realmente la ruta existe o no en un bloque if-else lo cual es muy práctico y reduce el código considerablemente. Lo que esta sección hace es verificar si existe el endpoint y ejecutarlo, si no existe llamará por defecto al endpoint 404 encargado de informar que la operación que intentan realizar no existe.

Hasta este punto, tenemos una API funcional sin autenticación, ahora vamos a implementar la autenticación a través de tokens.

Autenticación a través de token

Para esta sección vamos a iniciar por implementar la detección y captura de los parámetros o información enviada en la petición al endpoint.

Podemos implementarlo de la siguiente forma:

//capturar parámetros recibidos
switch ($_SERVER['REQUEST_METHOD']) {
case 'POST':
$requestData = $_POST;
break;
case 'GET':
$requestData = $_GET;
break;
case 'DELETE':
$requestData = $_DELETE;
break;
case 'PUT':
case 'PATCH':
parse_str(file_get_contents('php://input'), $requestData);

//si la información recibida no puede interpretarse como arreglo se ignora.
if (!is_array($requestData)) {
$requestData = array();
}

break;
default:
//TODO: implementa aquí cualquier otro tipo de request method que pueda presentarse.
break;
}

Detectamos el tipo de petición a partir de la entrada “REQUEST_METHOD” en el arreglo $_SERVER de PHP. Para peticiones POST, GET y DELETE es bastante sencillo, pero para peticiones de tipo PUT y PATCH es necesario analizar la cadena de texto ‘php://input’ y convertirla en arreglo usando la función parse_str(). Si en el futuro quieres cubrir otro tipo de peticiones puedes hacerlo ampliando el alcance del switch-case.

Esta sección del código cubrirá la recepción de los parámetros, incluyendo el token de seguridad que se utilice para realizarla, si el origen decide incluirlo en los elementos enviados a través de GET, POST, PUT, etc. Si se utiliza la autenticación usando headers, usaremos el siguiente código:

//Si el token es enviado en un header X-API-KEY
if (isset($_SERVER["HTTP_X_API_KEY"])) {
$requestData["token"] = $_SERVER["HTTP_X_API_KEY"];
}

Nuestra API usará uno de los headers más comunes para autenticación con token, es el header “X-API-KEY” este normalmente es usado por AWS y se ha convertido en uno de los más populares. Lo que hace nuestro código en este caso es, detectar si existe un header X-API-KEY y guardar el token recibido en el arreglo $requestData en el elemento con llave “token”.

En este punto ya estamos capturando toda la información necesaria para autenticar la petición y procesar los datos que sean enviados en ella.

Para autorizar o no las operaciones a partir del token, vamos a implementar un nuevo closure y lo agregaremos al listado de endpoints existentes. Esto más para ahorrarnos la implementación en una forma diferente dado el alcance del ejemplo.

El nuevo closure (endpoint) quedaría de la siguiente forma:

/**
* verifica si el token es válido, e impide la ejecución del endpoint solicitado.
* @param array $requestData contiene los parámetros enviados en la solicitud, para este endpoint se requiere un item con llave "token" que
* contenga el token recibido para autenticar y autorizar la petición.
* @return void
*/
$endpoints["checktoken"] = function ($requestData): void {

//puedes crear tokens seguros con esta línea, pero esa es una discusión para otra publicación.
//$token = str_replace("=", "", base64_encode(random_bytes(160 / 8)));

//tokens autorizados
$tokens = array(
"fa3b2c9c-a96d-48a8-82ad-0cb775dd3e5d" => ""
);

if (!isset($requestData["token"])) {
echo json_encode("No se recibió un token para autorizar la operación. Verifica la información enviada");
exit;
}

if (!isset($tokens[$requestData["token"]])) {
echo json_encode("El token " . $requestData["token"] . " no existe o no se encuentra autorizado para realizar esta operación.");
exit;
}
};

Esta función contiene la lista de tokens autorizados en el arreglo $tokens y validará si el token recibido existe o no en ella para determinar si la operación solicitada se ejecuta o no.

Para finalizar, aplicaremos un cambio al llamado de nuestros endpoints para que cada vez que se intente ejecutar uno, primero se ejecute el endpoint de validación de nuestro token, así:

//definimos el encoding de la respuesta, por defecto usaremos json
header("Content-Type: application/json; charset=UTF-8");

if (isset($endpoints[$endpointName])) {
// Validación del token recibido.
$endpoints["checktoken"]($requestData);
// Ejecución del endpoint solicitado.
$endpoints[$endpointName]($requestData);
} else {
$endpoints["404"](array("endpointName" => $endpointName));
}

Y ese sería el final de nuestro script, a continuación la versión completa de los archivos definitivos:

.htaccess

<Files "*.php">
Require ip 127.0.0.1
</Files>

<Files "index.php">
Require all granted
</Files>

RewriteEngine On
#Activate to force https.
#RewriteCond %{HTTPS} off
#RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L]

RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]

RewriteBase /simple-api/
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ index.php [QSA,L]

index.php

<?php

$BASE_URI = "/simple-api/";
$endpoints = array();
$requestData = array();

//capturar parámetros recibidos
switch ($_SERVER['REQUEST_METHOD']) {
case 'POST':
$requestData = $_POST;
break;
case 'GET':
$requestData = $_GET;
break;
case 'DELETE':
$requestData = $_DELETE;
break;
case 'PUT':
case 'PATCH':
parse_str(file_get_contents('php://input'), $requestData);

//si la información recibida no puede interpretarse
//como arreglo se ignora.
if (!is_array($requestData)) {
$requestData = array();
}

break;
default:
//TODO: implementa aquí cualquier otro tipo de
//request method que pueda presentarse.
break;
}

//Si el token es enviado en un header X-API-KEY
if (isset($_SERVER["HTTP_X_API_KEY"])) {
$requestData["token"] = $_SERVER["HTTP_X_API_KEY"];
}

$parsedURI = parse_url($_SERVER["REQUEST_URI"]);
$endpointName = str_replace($BASE_URI, "", $parsedURI["path"]);

if (empty($endpointName)) {
$endpointName = "/";
}

// closures para definir la lógica de cada endpoint,
// lo sé, esto se puede mejorar con un esquema OO pero es un ejemplo básico,
// no hagan esto en casa!

/**
* imprime un mensaje por defecto si se consulta la ruta base de la API.
* @param array $requestData contiene los parámetros enviados en
* la solicitud, para este endpoint son ignorados.
* @return void
*/
$endpoints["/"] = function (array $requestData): void {

echo json_encode("Bienvenido a mi API!");
};

/**
* imprime un mensaje de saludo con el nombre indicado en el item
* $requestData["name"] si la variable está vacía se usa un nombre por defecto.
*
* @param array $requestData este arreglo debe contener un item con llave
* "name" si quieres mostrar un nombre personalizado
* en el saludo.
* @return void
*/
$endpoints["sayhello"] = function (array $requestData): void {

if (!isset($requestData["name"])) {
$requestData["name"] = "Misterioso enmascarado";
}

echo json_encode("hello! " . $requestData["name"]);
};

/**
* imprime un mensaje por defecto si la ruta del endpoint no existe.
* @param array $requestData contiene los parámetros enviados en
* la solicitud, para este endpoint son ignorados.
* @return void
*/
$endpoints["404"] = function ($requestData): void {

echo json_encode("El endpoint " . $requestData["endpointName"] . " no fue encontrado.");
};

/**
* verifica si el token es válido, e impide la ejecución del
* endpoint solicitado.
* @param array $requestData contiene los parámetros enviados en
* la solicitud, para este endpoint se requiere un item
* con llave "token" que contenga el token recibido para
* autenticar y autorizar la petición.
* @return void
*/
$endpoints["checktoken"] = function ($requestData): void {

//puedes crear tokens seguros con esta línea,
//pero esa es una discusión para otra publicación, usaremos UUIDv4.
//$token = str_replace("=", "", base64_encode(random_bytes(160 / 8)));

//tokens autorizados
$tokens = array(
"fa3b2c9c-a96d-48a8-82ad-0cb775dd3e5d" => ""
);

if (!isset($requestData["token"])) {
echo json_encode("No se recibió un token para autorizar la operación.
Verifica la información enviada");

exit;
}

if (!isset($tokens[$requestData["token"]])) {
echo json_encode("El token " . $requestData["token"] .
" no existe o no se encuentra autorizado para realizar
esta operación.");

exit;
}
};

//definimos el encoding de la respuesta, por defecto usaremos json
header("Content-Type: application/json; charset=UTF-8");

if (isset($endpoints[$endpointName])) {
$endpoints["checktoken"]($requestData);
$endpoints[$endpointName]($requestData);
} else {
$endpoints["404"](array("endpointName" => $endpointName));
}

Pruebas

Para probar el script, puedes simplemente realizar peticiones GET desde tu navegador, en un entorno local puedes hacerlo ingresando a un URL como este:

http://localhost/simple-api/sayhello?token=fa3b2c9c-a96d-48a8-82ad-0cb775dd3e5d&name=john

Si quieres hacer una prueba usando POST puedes usar curl así:

curl -d "token=fa3b2c9c-a96d-48a8-82ad-0cb775dd3e5d&name=john" -X POST http://localhost/simple-api/sayhello

Usando el header X-API-KEY:

curl -H 'x-api-key: fa3b2c9c-a96d-48a8-82ad-0cb775dd3e5d' -d "name=john" -X POST http://localhost/simple-api/sayhello

Eso es todo. ¿Fácil, verdad?.

Ahora tienes una idea general sobre como opera una API y cómo puedes implementarla con fuerza de voluntad, coraje y un par de scripts.

En Winkhosting.co somos mucho más que hosting. No olvides visitarnos.

--

--