API RESTful ligera en PHP (sin framework) con JWT y PDO — Proyecto práctico

php API RESTful ligera en PHP (sin framework) con JWT y PDO — Proyecto práctico

API RESTful ligera en PHP (sin framework) con JWT y PDO — Proyecto práctico

Objetivo: construir una pequeña API RESTful en PHP sin frameworks que soporte registro, login (JWT) y endpoints protegidos. Código: mínimo, seguro y fácil de entender.

Requisitos previos

  • PHP 8.0+ con PDO y OpenSSL habilitado
  • Composer
  • MySQL o MariaDB (cualquier RDBMS compatible con PDO)
  • Conocimientos básicos de HTTP/JSON

Estructura de carpetas


project/
├─ public/
│  └─ index.php        # Front controller
├─ src/
│  ├─ bootstrap.php    # carga .env y dependencias
│  ├─ DB.php           # conexión PDO
│  ├─ Models/
│  │  └─ User.php
│  ├─ AuthController.php
│  └─ middleware/
│     └─ auth.php      # verifica JWT
├─ sql/
│  └─ migrations.sql
├─ vendor/
├─ .env.example
└─ composer.json

Instalación (rápida)


composer require firebase/php-jwt
# Copia .env.example a .env y ajusta valores
# Crea la tabla con sql/migrations.sql
# Sirve desde public/ (ej: php -S localhost:8000 -t public)

.env.example


DB_HOST=127.0.0.1
DB_NAME=myapi
DB_USER=root
DB_PASS=secret
JWT_SECRET=una_clave_muy_larga_y_secreta
JWT_ISSUER=http://localhost:8000
JWT_EXP=3600

SQL de migración (sql/migrations.sql)


CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Archivo: src/bootstrap.php


<?php
// src/bootstrap.php

// Tiny .env loader (no dependencias)
function load_env(string $path) : void {
    if (!file_exists($path)) return;
    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
        if (strpos(trim($line), '#') === 0) continue;
        [$key, $val] = array_map('trim', explode('=', $line, 2) + [null, null]);
        if ($key !== null && !array_key_exists($key, $_SERVER) && !array_key_exists($key, $_ENV)) {
            putenv("$key=$val");
            $_ENV[$key] = $val;
            $_SERVER[$key] = $val;
        }
    }
}

load_env(__DIR__ . '/../.env');

require_once __DIR__ . '/DB.php';
require_once __DIR__ . '/Models/User.php';
require_once __DIR__ . '/AuthController.php';
require_once __DIR__ . '/middleware/auth.php';

// Autoload de Composer
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
    require __DIR__ . '/../vendor/autoload.php';
}

Archivo: src/DB.php


<?php
// src/DB.php
class DB {
    private static ?\PDO $instance = null;

    public static function get(): \PDO {
        if (self::$instance) return self::$instance;

        $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";

        $pdo = new \PDO($dsn, $user, $pass, [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
            \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
        ]);
        self::$instance = $pdo;
        return $pdo;
    }
}

Archivo: src/Models/User.php


<?php
// src/Models/User.php

class User {
    private \PDO $pdo;

    public function __construct() {
        $this->pdo = DB::get();
    }

    public function create(string $name, string $email, string $password): int {
        $stmt = $this->pdo->prepare('INSERT INTO users (name, email, password) VALUES (?, ?, ?)');
        $stmt->execute([$name, $email, $password]);
        return (int)$this->pdo->lastInsertId();
    }

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

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

Archivo: src/AuthController.php

Contiene registro, login y generación de JWT.


<?php
// src/AuthController.php

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

class AuthController {
    private User $userModel;

    public function __construct() {
        $this->userModel = new User();
    }

    public function register(array $data) {
        $name = trim($data['name'] ?? '');
        $email = strtolower(trim($data['email'] ?? ''));
        $password = $data['password'] ?? '';

        if (!$name || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 6) {
            http_response_code(422);
            return ['error' => 'Datos inválidos'];
        }

        if ($this->userModel->findByEmail($email)) {
            http_response_code(409);
            return ['error' => 'Email ya registrado'];
        }

        $hash = password_hash($password, PASSWORD_DEFAULT);
        $id = $this->userModel->create($name, $email, $hash);
        http_response_code(201);
        return ['id' => $id, 'email' => $email, 'name' => $name];
    }

    public function login(array $data) {
        $email = strtolower(trim($data['email'] ?? ''));
        $password = $data['password'] ?? '';

        $user = $this->userModel->findByEmail($email);
        if (!$user || !password_verify($password, $user['password'])) {
            http_response_code(401);
            return ['error' => 'Credenciales inválidas'];
        }

        $token = $this->generateJWT((int)$user['id']);
        return ['token' => $token];
    }

    private function generateJWT(int $userId): string {
        $now = time();
        $exp = $now + ((int)getenv('JWT_EXP') ?: 3600);
        $payload = [
            'iss' => getenv('JWT_ISSUER') ?: 'http://localhost',
            'iat' => $now,
            'exp' => $exp,
            'sub' => $userId,
        ];
        $secret = getenv('JWT_SECRET') ?: 'change_me';
        return JWT::encode($payload, $secret, 'HS256');
    }

    public function me(int $userId) {
        $user = $this->userModel->findById($userId);
        if (!$user) { http_response_code(404); return ['error' => 'No encontrado']; }
        return $user;
    }
}

Archivo: src/middleware/auth.php


<?php
// src/middleware/auth.php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function getBearerToken(): ?string {
    $h = null;
    if (isset($_SERVER['HTTP_AUTHORIZATION'])) $h = $_SERVER['HTTP_AUTHORIZATION'];
    elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) $h = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    if (!$h) return null;
    if (preg_match('/Bearer\s+(\S+)/', $h, $m)) return $m[1];
    return null;
}

function authenticate(): ?int {
    $token = getBearerToken();
    if (!$token) {
        http_response_code(401);
        echo json_encode(['error' => 'Token no enviado']);
        exit;
    }

    try {
        $secret = getenv('JWT_SECRET') ?: 'change_me';
        $decoded = JWT::decode($token, new Key($secret, 'HS256'));
        return (int)$decoded->sub;
    } catch (Exception $e) {
        http_response_code(401);
        echo json_encode(['error' => 'Token inválido', 'msg' => $e->getMessage()]);
        exit;
    }
}

Archivo: public/index.php (front controller)


<?php
// public/index.php
require_once __DIR__ . '/../src/bootstrap.php';

header('Content-Type: application/json; charset=utf-8');
// Permitir CORS simple para desarrollo (ajustar en producción)
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;

$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');
$method = $_SERVER['REQUEST_METHOD'];

$authController = new AuthController();

// Rutas simples
if ($method === 'POST' && $uri === '/register') {
    $data = json_decode(file_get_contents('php://input'), true) ?: [];
    echo json_encode($authController->register($data));
    exit;
}

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

if ($method === 'GET' && $uri === '/me') {
    $userId = authenticate();
    echo json_encode($authController->me($userId));
    exit;
}

// Ejemplo endpoint protegido adicional
if ($method === 'GET' && $uri === '/protected') {
    $userId = authenticate();
    echo json_encode(['message' => 'Acceso permitido', 'user_id' => $userId]);
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'Ruta no encontrada']);

Por qué estas decisiones (breve)

  • PDO con prepared statements: evita SQL injection y es portable.
  • password_hash/password_verify: hashing seguro y compatible con cambios futuros de algoritmo.
  • JWT en HS256: sencillo para autenticación stateless. El token contiene sub (user id) y exp.
  • Front controller: fácil de entender y suficiente para APIs pequeñas.
  • Sin dependencias extra salvo firebase/php-jwt: reduce la complejidad y la superficie de ataque.

Buenas prácticas y recomendaciones

  • No guardes el JWT en localStorage en aplicaciones web; usa cookies con HttpOnly + Secure para mitigación XSS.
  • Rotación de claves y revocación: implementa refresh tokens y una lista de revocados si necesitas logout inmediato.
  • Valida y sanitiza input: aquí hay validaciones mínimas; en producción añade reglas más estrictas.
  • Mantén el secreto JWT en un gestor seguro (no en el repositorio).
  • Considera rate limiting y protección contra brute-force en /login.

Prueba rápida (curl):


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

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

# Acceder a ruta protegida
curl -H "Authorization: Bearer TOKEN_AQUI" http://localhost:8000/me

Siguiente paso práctico: añade refresh tokens con almacenamiento seguro (DB) y endpoints para revocación; además sustituye el almacenamiento del secreto por variables de entorno en un vault o secret manager para producción.

Advertencia de seguridad: este ejemplo es didáctico. Antes de usarlo en producción revisa manejo de CORS, almacenamiento seguro de JWT, protección contra XSS/CSRF, y políticas de rate limiting.

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