API RESTful en PHP con JWT, PDO y estructura modular

php API RESTful en PHP con JWT, PDO y estructura modular

API RESTful en PHP con JWT, PDO y estructura modular

Proyecto práctico: construimos una pequeña API REST que permite registro, login y obtener el perfil del usuario. Usamos PDO para consultas seguras y JWT para autenticación sin estado. Te doy la estructura, el código completo de los archivos principales y las razones detrás de cada decisión.

Requisitos previos

  • PHP 8+
  • Composer
  • MySQL o MariaDB
  • Extensiones: pdo_mysql

Estructura de carpetas


project/
  composer.json
  config.php
  public/
    index.php
    .htaccess
  src/
    Database.php
    Auth.php
    UserController.php
    Middleware.php

Base de datos (tabla users)


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

Instala la dependencia JWT:


composer require firebase/php-jwt

config.php

Archivo de configuración simple. En producción usa variables de entorno o un secret manager.

 [
    'host' => '127.0.0.1',
    'dbname' => 'api_db',
    'user' => 'api_user',
    'pass' => 'secret',
  ],
  'jwt' => [
    'secret' => 'CHANGE_THIS_TO_A_RANDOM_SECRET',
    'issuer' => 'your-domain.com',
    'aud' => 'your-domain.com',
    'expire' => 3600, // segundos
  ],
];

src/Database.php

 \PDO::ERRMODE_EXCEPTION,
        \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
      ]);
    }
    return self::$pdo;
  }
}

src/Auth.php

secret = $cfg['jwt']['secret'];
    $this->expire = $cfg['jwt']['expire'];
    $this->issuer = $cfg['jwt']['issuer'];
    $this->aud = $cfg['jwt']['aud'];
  }

  public function generateToken(int $userId): string
  {
    $now = time();
    $payload = [
      'iat' => $now,
      'nbf' => $now,
      'exp' => $now + $this->expire,
      'iss' => $this->issuer,
      'aud' => $this->aud,
      'sub' => $userId,
    ];
    return JWT::encode($payload, $this->secret, 'HS256');
  }

  public function verifyToken(string $token): ?array
  {
    try {
      $decoded = JWT::decode($token, new Key($this->secret, 'HS256'));
      // JWT::decode returns an object; convert to array
      return (array) $decoded;
    } catch (Exception $e) {
      return null;
    }
  }
}

src/UserController.php

db = $db;
    $this->auth = $auth;
  }

  public function register(array $data)
  {
    if (empty($data['email']) || empty($data['password'])) {
      return $this->json(400, ['error' => 'email y password son obligatorios']);
    }

    $email = strtolower(trim($data['email']));
    $passwordHash = password_hash($data['password'], PASSWORD_DEFAULT);
    $name = $data['name'] ?? null;

    try {
      $stmt = $this->db->prepare('INSERT INTO users (email, password, name) VALUES (:email, :password, :name)');
      $stmt->execute([':email' => $email, ':password' => $passwordHash, ':name' => $name]);
      $id = (int) $this->db->lastInsertId();
      return $this->json(201, ['id' => $id, 'email' => $email, 'name' => $name]);
    } catch (\PDOException $e) {
      if ($e->getCode() === '23000') { // duplicate
        return $this->json(409, ['error' => 'email ya registrado']);
      }
      return $this->json(500, ['error' => 'error de servidor']);
    }
  }

  public function login(array $data)
  {
    if (empty($data['email']) || empty($data['password'])) {
      return $this->json(400, ['error' => 'email y password son obligatorios']);
    }

    $stmt = $this->db->prepare('SELECT id, password FROM users WHERE email = :email');
    $stmt->execute([':email' => strtolower(trim($data['email']))]);
    $user = $stmt->fetch();

    if (!$user || !password_verify($data['password'], $user['password'])) {
      return $this->json(401, ['error' => 'credenciales inválidas']);
    }

    $token = $this->auth->generateToken((int) $user['id']);
    return $this->json(200, ['token' => $token, 'token_type' => 'bearer']);
  }

  public function profile(array $jwtPayload)
  {
    $userId = $jwtPayload['sub'] ?? null;
    if (!$userId) return $this->json(401, ['error' => 'token inválido']);

    $stmt = $this->db->prepare('SELECT id, email, name, created_at FROM users WHERE id = :id');
    $stmt->execute([':id' => $userId]);
    $user = $stmt->fetch();
    if (!$user) return $this->json(404, ['error' => 'usuario no encontrado']);

    return $this->json(200, ['user' => $user]);
  }

  private function json(int $status, array $data)
  {
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
  }
}

src/Middleware.php (autenticación)

auth = $auth;
  }

  public function authenticate(): ?array
  {
    $h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!$h || !str_starts_with($h, 'Bearer ')) return null;
    $token = trim(substr($h, 7));
    return $this->auth->verifyToken($token);
  }
}

public/index.php (router simple)

register($body);
  exit;
}

if ($method === 'POST' && $path === '/login') {
  $controller->login($body);
  exit;
}

if ($method === 'GET' && $path === '/me') {
  $payload = $middleware->authenticate();
  if (!$payload) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'no autorizado']);
    exit;
  }
  $controller->profile($payload);
  exit;
}

// 404
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'ruta no encontrada']);

public/.htaccess (opcional, para Apache)


RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]

Por qué así (decisiones técnicas)

  • PDO con prepared statements: evita inyecciones SQL y es fácil de usar.
  • password_hash / password_verify: manejo seguro de contraseñas con salt y algorítmos modernos.
  • JWT: token sin estado que permite escalabilidad. Firmado con HS256 y secreto en config (mejor en env).
  • Estructura modular: clases pequeñas (Database, Auth, Controller, Middleware) para mantener responsabilidad única y facilitar tests.
  • Control de errores mínimo y códigos HTTP adecuados: facilita debugging y cliente predecible.

Pruebas rápidas con curl


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

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

# Obtener perfil (reemplaza TOKEN por el token recibido)
curl -H 'Authorization: Bearer TOKEN' http://localhost/me

Mejoras y advertencias de seguridad

  • No guardes secretos en archivos de configuración en producción. Usa variables de entorno o un vault.
  • Implementa refresh tokens y revocación si necesitas logout forzado o control fino de sesiones.
  • Limita intentos de login y registra eventos de seguridad para detección de abuso.
  • Siempre sirve la API por HTTPS para proteger tokens en tránsito.
  • Firme tokens con una clave robusta y rota la clave periódicamente; considera firmar con claves asimétricas (RS256) para mayor seguridad y separación de roles.

Próximo paso: añade refresco de token con JWT + tabla de refresh tokens en la base de datos (almacena token hashed), y middleware para roles/permissions. También considera usar un micro-framework (Slim, Lumen) para routing más robusto y middleware reutilizable.

Consejo avanzado: si vas a escalar a múltiples instancias, evita revocar tokens simplemente borrando estado local: implementa una lista de revocación centralizada con TTL o usa short-living access tokens junto con refresh tokens almacenados de forma segura y rotables.

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