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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación