API REST en PHP (sin framework) con JWT y refresh tokens — Implementación segura

php API REST en PHP (sin framework) con JWT y refresh tokens — Implementación segura

API REST en PHP (sin framework) con JWT y refresh tokens — Implementación segura

Proyecto práctico: construir una API REST mínima en PHP (sin framework) con autenticación basada en JWT, refresh tokens seguros (hasheados en BD), middleware de protección y buenas prácticas de seguridad.

Requisitos previos

  • PHP 8.0+ (PDO, OpenSSL)
  • Composer
  • MySQL o MariaDB
  • Extensión openssl habilitada (para random_bytes)
  • Conocimientos básicos de JWT y HTTP

Estructura de carpetas

<project-root>
├── composer.json
├── config.php
├── migrations.sql
├── public
│   └── index.php        # punto de entrada (document root)
└── src
    ├── Database.php
    ├── UserModel.php
    ├── Auth.php
    └── Middleware.php

Instalación rápida

  1. composer require firebase/php-jwt
  2. Crear la base de datos y ejecutar migrations.sql
  3. Configurar config.php
  4. Apuntar el document root a la carpeta public/

migrations.sql

-- Usuarios
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(50) DEFAULT 'user',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Refresh tokens (guardamos hash del token para evitar almacenarlo en claro)
CREATE TABLE refresh_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token_hash CHAR(64) NOT NULL,
  expires_at DATETIME NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  ip VARCHAR(45),
  user_agent TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX (token_hash)
);

composer.json (mínimo)

{
  "require": {
    "firebase/php-jwt": "^6.0"
  },
  "autoload": {
    "psr-4": { "App\\": "src/" }
  }
}

config.php

<?php
return [
    'db' => [
        'dsn' => 'mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4',
        'user' => 'dbuser',
        'pass' => 'dbpass',
    ],

    // Clave secreta para firmar JWT. Úsala desde una variable de entorno.
    'jwt_secret' => getenv('JWT_SECRET') ?: 'please-change-this-secret',

    // Duraciones
    'access_ttl' => 900,           // 15 minutos
    'refresh_ttl' => 60 * 60 * 24 * 30 // 30 días
];

src/Database.php

<?php
namespace App;

use PDO;

class Database
{
    private static ?PDO $pdo = null;

    public static function get(array $config): PDO
    {
        if (self::$pdo === null) {
            self::$pdo = new PDO(
                $config['db']['dsn'],
                $config['db']['user'],
                $config['db']['pass'],
                [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
            );
        }

        return self::$pdo;
    }
}

src/UserModel.php

<?php
namespace App;

use PDO;

class UserModel
{
    private PDO $pdo;

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

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

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

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

src/Auth.php

<?php
namespace App;

use Firebase\JWT\JWT;
use PDO;

class Auth
{
    private PDO $pdo;
    private array $config;

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

    public function issueAccessToken(array $user): string
    {
        $now = time();
        $payload = [
            'iat' => $now,
            'exp' => $now + $this->config['access_ttl'],
            'sub' => $user['id'],
            'email' => $user['email'],
            'role' => $user['role'] ?? 'user'
        ];

        return JWT::encode($payload, $this->config['jwt_secret'], 'HS256');
    }

    // Crea y guarda un refresh token (almacenamos hash)
    public function createRefreshToken(int $userId, string $ip = null, string $ua = null): string
    {
        $token = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
        $hash = hash('sha256', $token);
        $expiresAt = (new \DateTime())->modify('+' . $this->config['refresh_ttl'] . ' seconds')->format('Y-m-d H:i:s');

        $stmt = $this->pdo->prepare('INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip, user_agent) VALUES (:uid, :hash, :exp, :ip, :ua)');
        $stmt->execute(['uid' => $userId, 'hash' => $hash, 'exp' => $expiresAt, 'ip' => $ip, 'ua' => $ua]);

        return $token; // devuelto al cliente en texto claro una sola vez
    }

    public function rotateRefreshToken(string $oldToken, int $userId, string $ip = null, string $ua = null): ?string
    {
        // Borrar token viejo y emitir uno nuevo en una transacción
        $this->pdo->beginTransaction();
        try {
            $oldHash = hash('sha256', $oldToken);
            $stmt = $this->pdo->prepare('SELECT id, expires_at FROM refresh_tokens WHERE token_hash = :h AND user_id = :uid');
            $stmt->execute(['h' => $oldHash, 'uid' => $userId]);
            $row = $stmt->fetch(PDO::FETCH_ASSOC);
            if (! $row) {
                $this->pdo->rollBack();
                return null;
            }

            $expires = new \DateTime($row['expires_at']);
            if ($expires < new \DateTime()) {
                // Expired: remove and bail
                $del = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE id = :id');
                $del->execute(['id' => $row['id']]);
                $this->pdo->commit();
                return null;
            }

            // Delete old token
            $del = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE id = :id');
            $del->execute(['id' => $row['id']]);

            // Create new token
            $newToken = $this->createRefreshToken($userId, $ip, $ua);
            $this->pdo->commit();
            return $newToken;
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    public function validateRefreshToken(string $token): ?array
    {
        $hash = hash('sha256', $token);
        $stmt = $this->pdo->prepare('SELECT rt.id, rt.user_id, rt.expires_at, u.email FROM refresh_tokens rt JOIN users u ON u.id = rt.user_id WHERE rt.token_hash = :h');
        $stmt->execute(['h' => $hash]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (! $row) return null;

        $expires = new \DateTime($row['expires_at']);
        if ($expires < new \DateTime()) {
            // Token expirado: borra
            $del = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE id = :id');
            $del->execute(['id' => $row['id']]);
            return null;
        }

        return $row; // contiene user_id
    }

    public function revokeRefreshToken(string $token): void
    {
        $hash = hash('sha256', $token);
        $stmt = $this->pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = :h');
        $stmt->execute(['h' => $hash]);
    }
}

src/Middleware.php

<?php
namespace App;

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

class Middleware
{
    private array $config;

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    // Extrae el usuario desde el header Authorization: Bearer <token>
    public function authFromRequest(callable $next)
    {
        return function ($req) use ($next) {
            $headers = getallheaders();
            $auth = $headers['Authorization'] ?? $headers['authorization'] ?? null;
            if (! $auth || ! preg_match('/Bearer\s+(\S+)/', $auth, $m)) {
                http_response_code(401);
                echo json_encode(['error' => 'missing_token']);
                return;
            }

            $token = $m[1];
            try {
                $decoded = JWT::decode($token, new Key($this->config['jwt_secret'], 'HS256'));
                // attach user info to request (simple associative array in this example)
                $req['user'] = ['id' => $decoded->sub, 'email' => $decoded->email, 'role' => $decoded->role];
                return $next($req);
            } catch (\Throwable $e) {
                http_response_code(401);
                echo json_encode(['error' => 'invalid_token', 'message' => $e->getMessage()]);
                return;
            }
        };
    }
}

public/index.php

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

use App\Database;
use App\UserModel;
use App\Auth;
use App\Middleware;

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

$config = require __DIR__ . '/../config.php';
$pdo = Database::get($config);
$userModel = new UserModel($pdo);
$auth = new Auth($pdo, $config);
$middleware = new Middleware($config);

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

// Helpers
$body = json_decode(file_get_contents('php://input') ?: '{}', true) ?: [];

// Endpoints
if ($method === 'POST' && $path === '/register') {
    $email = $body['email'] ?? '';
    $password = $body['password'] ?? '';
    if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
        http_response_code(400);
        echo json_encode(['error' => 'invalid_input']);
        exit;
    }

    try {
        $id = $userModel->create($email, $password);
        echo json_encode(['id' => $id, 'email' => $email]);
    } catch (\PDOException $e) {
        http_response_code(400);
        echo json_encode(['error' => 'user_exists']);
    }
    exit;
}

if ($method === 'POST' && $path === '/login') {
    $email = $body['email'] ?? '';
    $password = $body['password'] ?? '';
    $user = $userModel->findByEmail($email);
    if (! $user || ! password_verify($password, $user['password_hash'])) {
        http_response_code(401);
        echo json_encode(['error' => 'invalid_credentials']);
        exit;
    }

    $access = $auth->issueAccessToken($user);
    $refresh = $auth->createRefreshToken($user['id'], $_SERVER['REMOTE_ADDR'] ?? null, $_SERVER['HTTP_USER_AGENT'] ?? null);

    echo json_encode(['access_token' => $access, 'expires_in' => $config['access_ttl'], 'refresh_token' => $refresh]);
    exit;
}

if ($method === 'POST' && $path === '/refresh') {
    $refresh = $body['refresh_token'] ?? '';
    if (! $refresh) {
        http_response_code(400);
        echo json_encode(['error' => 'missing_refresh']);
        exit;
    }

    $valid = $auth->validateRefreshToken($refresh);
    if (! $valid) {
        http_response_code(401);
        echo json_encode(['error' => 'invalid_refresh']);
        exit;
    }

    // rotate
    $newRefresh = $auth->rotateRefreshToken($refresh, (int)$valid['user_id'], $_SERVER['REMOTE_ADDR'] ?? null, $_SERVER['HTTP_USER_AGENT'] ?? null);
    if (! $newRefresh) {
        http_response_code(401);
        echo json_encode(['error' => 'invalid_refresh']);
        exit;
    }

    $user = $userModel->findById((int)$valid['user_id']);
    $access = $auth->issueAccessToken($user);

    echo json_encode(['access_token' => $access, 'expires_in' => $config['access_ttl'], 'refresh_token' => $newRefresh]);
    exit;
}

if ($method === 'POST' && $path === '/logout') {
    $refresh = $body['refresh_token'] ?? '';
    if ($refresh) {
        $auth->revokeRefreshToken($refresh);
    }
    echo json_encode(['ok' => true]);
    exit;
}

// Protected route example: GET /me
if ($method === 'GET' && $path === '/me') {
    $handler = function ($req) use ($userModel) {
        $user = $userModel->findById((int)$req['user']['id']);
        echo json_encode(['user' => $user]);
    };

    $protected = $middleware->authFromRequest($handler);
    $protected([]);
    exit;
}

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

Por qué estas decisiones (explicación)

  • Usar JWT firmado (HS256) para tokens de acceso: fácil de verificar en cada petición sin tocar la BD para cada request. Buen trade-off para tokens cortos (15 min).
  • Refresh tokens long-lived y hasheados en BD: nunca almacenes el token en claro. Si la BD se filtra, hashes hacen más difícil su uso. Además permite revocar tokens (logout).
  • Rotación de refresh tokens: al intercambiar un refresh token, borramos el antiguo y creamos uno nuevo para mitigar el uso de tokens robados (replay).
  • Usar password_hash / password_verify: delega el algoritmo y ajustes de cost a PHP (bcrypt/argon2 si disponible).
  • PDO con prepared statements: evita inyección SQL.
  • No confiamos en datos del JWT para operaciones sensibles: cuando necesites información crítica, recarga desde la BD si es necesario.

Pruebas básicas (curl)

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

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

# Use access token
curl -H 'Authorization: Bearer <access_token>' http://localhost/me

# Refresh
curl -X POST -H 'Content-Type: application/json' -d '{"refresh_token":"<refresh>"}' http://localhost/refresh

Buenas prácticas y consideraciones de seguridad

  • Usa HTTPS siempre: los tokens en tránsito deben estar cifrados.
  • Guarda la secret <<jwt_secret>> en variables de entorno, no en el repo.
  • Considera implementar limitadores de velocidad (rate limiting) en endpoints de login/refresh.
  • Para aplicaciones web: guarda refresh tokens en cookies HttpOnly y SameSite=strict si es viable; evita exposición en JavaScript.
  • Audita el uso de refresh tokens (ip, user_agent) y detecta anomalías.

Siguiente paso: implementa rotación completa con detección de reuse — si un refresh token ya rotado aparece, revoca todos los tokens del usuario y fuerza re-login. Esto mitiga ataques donde un token robado se usa después de haber sido rotado.

Advertencia avanzada: revocar tokens y logout seguro es complejo en arquitecturas distribuidas. Considera una lista de revocación corta en memoria o un store rápido (Redis) para invalidar tokens a nivel global sin consultar la BD en cada request.

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