Cómo construir una API RESTful segura en PHP con JWT y Eloquent

php Cómo construir una API RESTful segura en PHP con JWT y Eloquent

Cómo construir una API RESTful segura en PHP con JWT y Eloquent

En este tutorial práctico vas a crear una API REST básica en PHP que cubre registro, login con JWT y acceso protegido a recursos usando Eloquent como ORM. Incluye estructura de carpetas, código completo y explicaciones de por qué se hace cada cosa.

Estructura de carpetas

my-api/
├─ app/
│  ├─ Controllers/
│  │  └─ AuthController.php
│  ├─ Middleware/
│  │  └─ JwtMiddleware.php
│  └─ Models/
│     └─ User.php
├─ public/
│  └─ index.php
├─ bootstrap.php
├─ routes.php
├─ composer.json
└─ .env

Dependencias (composer)

{
  "require": {
    "php": ">=7.4",
    "illuminate/database": "^8.0",
    "vlucas/phpdotenv": "^5.0",
    "firebase/php-jwt": "^6.0",
    "nikic/fast-route": "^1.3"
  },
  "autoload": {
    "psr-4": {
      "App\\": "app/"
    }
  }
}

Instala con:

composer install

.env (ejemplo)

APP_ENV=local
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapi
DB_USERNAME=root
DB_PASSWORD=secret
JWT_SECRET=tu_clave_secreta_muy_larga

bootstrap.php

<?php
use Illuminate\Database\Capsule\Manager as Capsule;

require __DIR__ . '/vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

$capsule = new Capsule;
$capsule->addConnection([
    'driver'    => getenv('DB_CONNECTION'),
    'host'      => getenv('DB_HOST'),
    'database'  => getenv('DB_DATABASE'),
    'username'  => getenv('DB_USERNAME'),
    'password'  => getenv('DB_PASSWORD'),
    'charset'   => 'utf8',
    'collation' => 'utf8_unicode_ci',
    'prefix'    => '',
]);

$capsule->setAsGlobal();
$capsule->bootEloquent();

Modelo: app/Models/User.php

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'users';
    protected $fillable = ['name', 'email', 'password'];
    protected $hidden = ['password'];
    public $timestamps = true;
}

Controlador: app/Controllers/AuthController.php

<?php
namespace App\Controllers;

use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class AuthController
{
    public function register($data)
    {
        // Validación mínima
        if (!isset($data['email'], $data['password'], $data['name'])) {
            http_response_code(422);
            return ['error' => 'Faltan campos'];
        }

        if (User::where('email', $data['email'])->exists()) {
            http_response_code(409);
            return ['error' => 'Email ya registrado'];
        }

        $user = User::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => password_hash($data['password'], PASSWORD_DEFAULT),
        ]);

        http_response_code(201);
        return ['user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email]];
    }

    public function login($data)
    {
        if (!isset($data['email'], $data['password'])) {
            http_response_code(422);
            return ['error' => 'Faltan campos'];
        }

        $user = User::where('email', $data['email'])->first();
        if (!$user || !password_verify($data['password'], $user->password)) {
            http_response_code(401);
            return ['error' => 'Credenciales inválidas'];
        }

        $now = time();
        $payload = [
            'iat' => $now,
            'exp' => $now + 3600, // 1 hora
            'sub' => $user->id,
        ];

        $jwt = JWT::encode($payload, getenv('JWT_SECRET'), 'HS256');

        return ['token' => $jwt];
    }

    public function profile($userId)
    {
        $user = User::find($userId);
        if (!$user) {
            http_response_code(404);
            return ['error' => 'Usuario no encontrado'];
        }

        return ['user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email]];
    }
}

Middleware JWT: app/Middleware/JwtMiddleware.php

<?php
namespace App\Middleware;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class JwtMiddleware
{
    public static function getUserIdFromHeader()
    {
        $headers = getallheaders();
        if (!isset($headers['Authorization'])) return null;

        $auth = $headers['Authorization'];
        if (strpos($auth, 'Bearer ') !== 0) return null;

        $token = trim(str_replace('Bearer ', '', $auth));

        try {
            $decoded = JWT::decode($token, new Key(getenv('JWT_SECRET'), 'HS256'));
            return $decoded->sub ?? null;
        } catch (\Exception $e) {
            return null;
        }
    }
}

Rutas: routes.php

<?php
use App\Controllers\AuthController;
use App\Middleware\JwtMiddleware;

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    $r->addRoute('POST', '/register', ['AuthController', 'register']);
    $r->addRoute('POST', '/login', ['AuthController', 'login']);
    $r->addRoute('GET', '/profile', ['AuthController', 'profile']);
});

// dispatcher se devolverá al public/index.php para ejecutar
return $dispatcher;

Front controller: public/index.php

<?php
require __DIR__ . '/../bootstrap.php';

use App\Controllers\AuthController;
use App\Middleware\JwtMiddleware;

$dispatcher = require __DIR__ . '/../routes.php';

$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

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

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
$controller = new AuthController();

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];

        list($class, $method) = $handler;

        // Simple mapping a métodos del controlador
        $input = json_decode(file_get_contents('php://input'), true) ?? [];

        if ($method === 'profile') {
            $userId = JwtMiddleware::getUserIdFromHeader();
            if (!$userId) {
                http_response_code(401);
                echo json_encode(['error' => 'Token inválido o ausente']);
                break;
            }
            $response = $controller->profile($userId);
            echo json_encode($response);
            break;
        }

        if ($method === 'register') {
            $response = $controller->register($input);
            echo json_encode($response);
            break;
        }

        if ($method === 'login') {
            $response = $controller->login($input);
            echo json_encode($response);
            break;
        }

        // fallback
        http_response_code(500);
        echo json_encode(['error' => 'Handler no implementado']);
        break;
}

DB: tabla users (ejemplo MySQL)

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(150) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NULL DEFAULT NULL,
  updated_at TIMESTAMP NULL DEFAULT NULL
);

Pruebas con curl

# Registrar
curl -X POST http://localhost:8000/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Juan","email":"juan@ejemplo.com","password":"secret"}'

# Login
curl -X POST http://localhost:8000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"juan@ejemplo.com","password":"secret"}'

# Acceder a perfil (reemplazar TOKEN)
curl -X GET http://localhost:8000/profile \
  -H "Authorization: Bearer TOKEN"

Por qué esta arquitectura

  • FastRoute: routing simple y rápido sin framework completo.
  • Eloquent: ORM maduro para consultas y modelos, reduce boilerplate SQL.
  • JWT: tokens compactos y stateless para APIs. Ideal para microservicios y SPAs.
  • Dotenv: separar configuración sensible del código.
  • Middleware: separación clara de responsabilidades (autenticación fuera del controlador).

Seguridad y buenas prácticas que debes aplicar

  • Usa HTTPS obligatorio en producción: JWT en claro es inseguro en HTTP.
  • Almacena JWT_SECRET en un gestor de secretos o variables de entorno seguras.
  • No guardes tokens de larga duración sin control: implementar refresh tokens y revocación (blacklist) si necesitas logout inmediato.
  • Valida entradas con reglas robustas (email, password strength) y maneja errores con códigos HTTP adecuados.
  • Considera claims adicionales en JWT (aud, iss) y validación de los mismos.
  • Control de versiones de la API y límites de tasa (rate limiting).

Extensiones recomendadas

  • Implementar refresh tokens y rotación de claves.
  • Añadir tests automáticos (PHPUnit) para endpoints y middleware.
  • Integrar logging estructurado y monitoreo (Sentry, Prometheus).

Próximo paso sugerido: implementa refresh tokens con almacenamiento en base de datos y listas de revocación para poder invalidar sesiones. Ten en cuenta la rotación de claves JWT y siempre obliga HTTPS en producción.

Advertencia: nunca expongas tu JWT_SECRET en repositorios públicos y revisa la duración de tus tokens según el riesgo de la aplicación.

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