API REST con PHP 8: JWT, validación, PDO y rate limiting (ejemplo práctico)

php API REST con PHP 8: JWT, validación, PDO y rate limiting (ejemplo práctico)

API REST con PHP 8: JWT, validación, PDO y rate limiting

Te muestro un ejemplo práctico y ligero para levantar una API REST en PHP 8 que incluya:

  • Autenticación con JWT
  • Acceso seguro a base de datos con PDO
  • Validación básica de entrada
  • Limitación de tasa (rate limiting) con Redis

La idea es centrarnos en patrones reutilizables: middleware de autorización, emisión de tokens y control de acceso.

Dependencias (Composer)

composer require nikic/fast-route firebase/php-jwt predis/predis

Estructura mínima

project/
├─ public/index.php
├─ src/db.php
├─ src/auth.php
├─ src/rate_limiter.php
└─ composer.json

1) Conexión PDO (src/db.php)

<?php
function getPDO(): PDO
{
    $host = getenv('DB_HOST') ?: '127.0.0.1';
    $db   = getenv('DB_NAME') ?: 'test';
    $user = getenv('DB_USER') ?: 'root';
    $pass = getenv('DB_PASS') ?: '';
    $dsn = "mysql:host={$host};dbname={$db};charset=utf8mb4";

    $opts = [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ];

    return new PDO($dsn, $user, $pass, $opts);
}

2) Autenticación y JWT (src/auth.php)

Usamos firebase/php-jwt. Guarda la clave en una variable de entorno (JWT_SECRET).

<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function issueToken(array $payload): string
{
    $secret = getenv('JWT_SECRET') ?: 'change_me';
    $now = time();
    $token = array_merge([
        'iat' => $now,
        'exp' => $now + 3600, // 1 hora
    ], $payload);

    return JWT::encode($token, $secret, 'HS256');
}

function decodeToken(string $jwt): array
{
    $secret = getenv('JWT_SECRET') ?: 'change_me';
    try {
        $decoded = JWT::decode($jwt, new Key($secret, 'HS256'));
        return (array) $decoded;
    } catch (\Throwable $e) {
        throw new RuntimeException('Token inválido: ' . $e->getMessage());
    }
}

function getBearerToken(): ?string
{
    $hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? null);
    if (!$hdr) return null;
    if (preg_match('/Bearer\s+(.*)$/i', $hdr, $m)) return $m[1];
    return null;
}

3) Rate limiter simple con Redis (src/rate_limiter.php)

<?php
use Predis\Client;

function rateLimit(string $key, int $limit = 100, int $window = 60): bool
{
    // key: prefijo + ip o usuario
    $redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
    $redis = new Client(['host' => $redisHost]);

    $current = $redis->incr($key);
    if ($current === 1) {
        $redis->expire($key, $window);
    }
    return $current <= $limit;
}

4) Router y endpoints (public/index.php)

Ejemplo mínimo con FastRoute. Se asume que puedes usar mod_rewrite o configurar el webserver para apuntar a este archivo.

<?php
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../src/db.php';
require __DIR__ . '/../src/auth.php';
require __DIR__ . '/../src/rate_limiter.php';

use FastRoute\RouteCollector;

$dispatcher = FastRoute\simpleDispatcher(function(RouteCollector $r) {
    $r->addRoute('POST', '/login', 'login');
    $r->addRoute('POST', '/users', 'create_user');
    $r->addRoute('GET', '/users/{id:\\d+}', 'get_user');
});

// Fetch method and URI
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

// Strip query string
if (false !== $pos = strpos($uri, '?')) {
    $uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
    case FastRoute\Dispatcher::NOT_FOUND:
        http_response_code(404);
        echo json_encode(['error' => 'Not Found']);
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        http_response_code(405);
        echo json_encode(['error' => 'Method Not Allowed']);
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];

        header('Content-Type: application/json');

        // Simple per-IP rate limit
        $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
        $key = "rate:{$ip}";
        if (!rateLimit($key, 100, 60)) {
            http_response_code(429);
            echo json_encode(['error' => 'Too Many Requests']);
            break;
        }

        // Dispatch handlers
        if ($handler === 'login') {
            $body = json_decode(file_get_contents('php://input'), true) ?: [];
            $email = $body['email'] ?? '';
            $password = $body['password'] ?? '';

            if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !$password) {
                http_response_code(400);
                echo json_encode(['error' => 'Invalid input']);
                break;
            }

            $pdo = getPDO();
            $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
            $stmt->execute([$email]);
            $user = $stmt->fetch();
            if (!$user || !password_verify($password, $user['password_hash'])) {
                http_response_code(401);
                echo json_encode(['error' => 'Invalid credentials']);
                break;
            }

            $token = issueToken(['sub' => $user['id']]);
            echo json_encode(['token' => $token]);
            break;
        }

        if ($handler === 'create_user') {
            $body = json_decode(file_get_contents('php://input'), true) ?: [];
            $email = $body['email'] ?? '';
            $password = $body['password'] ?? '';

            if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
                http_response_code(400);
                echo json_encode(['error' => 'Invalid input']);
                break;
            }

            $pdo = getPDO();
            $stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
            $hash = password_hash($password, PASSWORD_DEFAULT);
            try {
                $stmt->execute([$email, $hash]);
                http_response_code(201);
                echo json_encode(['id' => $pdo->lastInsertId()]);
            } catch (PDOException $e) {
                http_response_code(500);
                echo json_encode(['error' => 'Database error']);
            }
            break;
        }

        if ($handler === 'get_user') {
            // Protected endpoint
            $bearer = getBearerToken();
            if (!$bearer) {
                http_response_code(401);
                echo json_encode(['error' => 'Token required']);
                break;
            }

            try {
                $payload = decodeToken($bearer);
            } catch (RuntimeException $e) {
                http_response_code(401);
                echo json_encode(['error' => 'Invalid token']);
                break;
            }

            $userId = (int) $vars['id'];
            // Authorization: allow users to fetch only their own data (example)
            if ($payload['sub'] !== $userId) {
                http_response_code(403);
                echo json_encode(['error' => 'Forbidden']);
                break;
            }

            $pdo = getPDO();
            $stmt = $pdo->prepare('SELECT id, email FROM users WHERE id = ?');
            $stmt->execute([$userId]);
            $u = $stmt->fetch();
            if (!$u) {
                http_response_code(404);
                echo json_encode(['error' => 'User not found']);
                break;
            }

            echo json_encode($u);
            break;
        }

        http_response_code(500);
        echo json_encode(['error' => 'Unhandled route']);
        break;
}

Buenas prácticas y puntos importantes

  • Usa prepared statements (PDO) para evitar SQL injection — ya está aplicado en los ejemplos.
  • No guardes secretos en el repositorio: usa variables de entorno o un secret manager para JWT_SECRET y credenciales DB/Redis.
  • Haz que los tokens expiren y considera un mecanismo de refresh tokens si necesitas sesiones persistentes.
  • Valida siempre el input (tipo, longitud, formato) antes de operar sobre la base de datos.
  • Si necesitas mayor propósitos de seguridad (rotación de claves, múltiples algoritmos) implementa 'kid' y JWKs, y usa librerías que gestionen esa complejidad.

Implementa pruebas automáticas (unitarias y de integración) para las rutas y la lógica de autorización. Como siguiente paso natural puedes añadir una capa de caching para endpoints de lectura y un endpoint de revocación de tokens usando una lista negra almacenada en Redis o en una tabla de DB.

Consejo avanzado: evita tokens JWT largos y permanentes. Emite access tokens cortos (p. ej. 15–60 min) y maneja refresh tokens con control de revocación en servidor; además, almacena la versión del token (token version) en la base de datos para poder invalidar tokens cuando cambie la contraseña o se detecte compromiso de cuenta.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión