API RESTful en PHP con JWT y PDO — tutorial práctico

php API RESTful en PHP con JWT y PDO — tutorial práctico

API RESTful en PHP con JWT y PDO — tutorial práctico

Construiremos una API mínima en PHP que permite registro, login y acceso a un recurso protegido mediante JWT. Usaremos PDO para consultas seguras a MySQL y firebase/php-jwt para tokens. El objetivo es práctico: código funcional y explicaciones de por qué se toman ciertas decisiones.

Requisitos previos

  • PHP ≥ 7.4 con extensiones pdo_mysql y openssl
  • Composer
  • MySQL o MariaDB
  • Servidor web que apunte la raíz a la carpeta public/ (Apache, Nginx)

Estructura de carpetas

project/
├─ public/
│  └─ index.php
├─ src/
│  ├─ Database.php
│  ├─ User.php
│  └─ AuthController.php
├─ vendor/
├─ .env
├─ composer.json
└─ sql/
   └─ schema.sql

Dependencias

Instala con Composer:

composer require firebase/php-jwt vlucas/phpdotenv

Esquema SQL

-- sql/schema.sql
CREATE TABLE users (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

.env (ejemplo)

DB_HOST=127.0.0.1
DB_NAME=myapi
DB_USER=myuser
DB_PASS=mypass
JWT_SECRET=una_clave_muy_larga_y_segura
JWT_ISSUER=http://localhost
JWT_AUDIENCE=http://localhost
JWT_EXP=900   # segundos (15 minutos)

Archivo: src/Database.php

<?php
namespace App;

use PDO;
use PDOException;

class Database
{
    private static $pdo;

    public static function getConnection(): PDO
    {
        if (self::$pdo === null) {
            $host = getenv('DB_HOST');
            $db   = getenv('DB_NAME');
            $user = getenv('DB_USER');
            $pass = getenv('DB_PASS');
            $dsn  = "mysql:host={$host};dbname={$db};charset=utf8mb4";

            $options = [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
            ];

            try {
                self::$pdo = new PDO($dsn, $user, $pass, $options);
            } catch (PDOException $e) {
                http_response_code(500);
                echo json_encode(["error" => "DB connection failed"]);
                exit;
            }
        }

        return self::$pdo;
    }
}

Archivo: src/User.php

<?php
namespace App;

use PDO;

class User
{
    private $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function create(string $name, string $email, string $password): array
    {
        $hash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $this->pdo->prepare('INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :hash)');
        $stmt->execute(['name' => $name, 'email' => $email, 'hash' => $hash]);

        return [
            'id' => (int)$this->pdo->lastInsertId(),
            'name' => $name,
            'email' => $email,
        ];
    }

    public function findByEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id, name, email, password_hash FROM users WHERE email = :email');
        $stmt->execute(['email' => $email]);
        $user = $stmt->fetch();
        return $user ?: null;
    }
}

Archivo: src/AuthController.php

<?php
namespace App;

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

class AuthController
{
    private $userModel;
    private $jwtSecret;
    private $issuer;
    private $aud;
    private $exp;

    public function __construct(User $userModel)
    {
        $this->userModel = $userModel;
        $this->jwtSecret = getenv('JWT_SECRET');
        $this->issuer = getenv('JWT_ISSUER');
        $this->aud = getenv('JWT_AUDIENCE');
        $this->exp = (int)getenv('JWT_EXP') ?: 900;
    }

    public function register(array $data)
    {
        if (empty($data['name']) || empty($data['email']) || empty($data['password'])) {
            http_response_code(422);
            echo json_encode(['error' => 'Missing fields']);
            return;
        }

        // Simple validation; expand as needed
        if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            http_response_code(422);
            echo json_encode(['error' => 'Invalid email']);
            return;
        }

        // Check exists
        if ($this->userModel->findByEmail($data['email'])) {
            http_response_code(409);
            echo json_encode(['error' => 'Email already registered']);
            return;
        }

        $user = $this->userModel->create($data['name'], $data['email'], $data['password']);
        http_response_code(201);
        echo json_encode(['user' => $user]);
    }

    public function login(array $data)
    {
        if (empty($data['email']) || empty($data['password'])) {
            http_response_code(422);
            echo json_encode(['error' => 'Missing fields']);
            return;
        }

        $user = $this->userModel->findByEmail($data['email']);
        if (!$user || !password_verify($data['password'], $user['password_hash'])) {
            http_response_code(401);
            echo json_encode(['error' => 'Invalid credentials']);
            return;
        }

        $now = new DateTimeImmutable();
        $exp = $now->modify("+{$this->exp} seconds")->getTimestamp();

        $payload = [
            'iat' => $now->getTimestamp(),
            'iss' => $this->issuer,
            'nbf' => $now->getTimestamp(),
            'exp' => $exp,
            'aud' => $this->aud,
            'sub' => $user['id'],
            'email' => $user['email']
        ];

        $jwt = JWT::encode($payload, $this->jwtSecret, 'HS256');

        echo json_encode(['access_token' => $jwt, 'token_type' => 'Bearer', 'expires_in' => $this->exp]);
    }

    public function me(array $claims)
    {
        // claims comes from the validated token
        echo json_encode(['user' => ['id' => $claims->sub, 'email' => $claims->email]]);
    }
}

Archivo público: public/index.php

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

use App\Database;
use App\User;
use App\AuthController;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// Load env
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();

header('Content-Type: application/json; charset=utf-8');

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

$pdo = Database::getConnection();
$userModel = new User($pdo);
$auth = new AuthController($userModel);

// Simple router
if ($path === '/register' && $method === 'POST') {
    $data = json_decode(file_get_contents('php://input'), true);
    $auth->register($data ?? []);
    exit;
}

if ($path === '/login' && $method === 'POST') {
    $data = json_decode(file_get_contents('php://input'), true);
    $auth->login($data ?? []);
    exit;
}

// Protected route example: /me
if ($path === '/me' && $method === 'GET') {
    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
        http_response_code(401);
        echo json_encode(['error' => 'No token provided']);
        exit;
    }

    $token = $matches[1];
    try {
        $secret = getenv('JWT_SECRET');
        $decoded = JWT::decode($token, new Key($secret, 'HS256'));

        // Optionally: validate aud/iss here
        $auth->me($decoded);
    } catch (Exception $e) {
        http_response_code(401);
        echo json_encode(['error' => 'Token invalid or expired', 'message' => $e->getMessage()]);
    }
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'Not found']);

Pruebas rápidas (curl)

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

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

# Acceder a /me con el token devuelto
curl -H "Authorization: Bearer TOKEN" http://localhost/me

Por qué así (decisiones importantes)

  • PDO con prepared statements: evita inyección SQL y es estándar en PHP.
  • password_hash / password_verify: hashing fuerte y actualización automática de algoritmos.
  • JWT firmado con HS256 y secreto robusto: permite que la API sea stateless y escalable. No almacenes información sensible en el payload.
  • Token con expiración corta (ej. 15 min): limita el impacto en caso de fuga. Implementa refresh tokens si necesitas sesiones largas.
  • Entry point único (public/index.php): facilita control de CORS, logs y middleware centralizado.
  • Uso de dotenv: evita hardcodear credenciales en el código.

Mejoras y consideraciones de seguridad

  • Forzar HTTPS en producción para proteger Authorization headers.
  • Rotación de claves y soporte para tokens revocables (lista negra o usar revocation table).
  • Limitar intentos de login (rate limiting) y registrar intentos fallidos.
  • Validar aud y iss en el JWT para prevenir uso en contextos distintos.
  • Considera usar algoritmos asimétricos (RS256) si necesitas delegar firma/validación entre servicios.

Si vas a seguir: implementa refresh tokens seguros (almacenados y rotados), añade validación más robusta de inputs (length, blacklist de password simples), y crea un middleware reutilizable para rutas protegidas. Una advertencia clave: nunca expongas tu JWT_SECRET ni lo comprimas en el repositorio.

Consejo avanzado: para alta escala, cambia a RS256 con claves privadas protegidas en un HSM o KMS y distribuye la clave pública a tus microservicios para validar tokens sin compartir secretos.

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