API REST en PHP con autenticación JWT y PDO (registro, login y perfil)

php API REST en PHP con autenticación JWT y PDO (registro, login y perfil)

API REST en PHP con autenticación JWT y PDO (registro, login y perfil)

Proyecto práctico: crea una pequeña API segura en PHP que permita registrar usuarios, iniciar sesión (devuelve un JWT) y acceder a una ruta protegida (/profile). Usaremos PDO para consultas seguras, password_hash/password_verify y JWT para tokens.

Requisitos previos

  • PHP 8.0+
  • Composer
  • MySQL o MariaDB
  • Servidor web (Apache/Nginx) o built-in server de PHP

Dependencias (Composer)

Usaremos:

  • vlucas/phpdotenv para variables de entorno
  • firebase/php-jwt para generar/validar JWT
{
  "require": {
    "vlucas/phpdotenv": "^5.5",
    "firebase/php-jwt": "^6.0"
  },
  "autoload": {
    "psr-4": { "App\\": "src/" }
  }
}

Estructura de carpetas

project-root/
├─ composer.json
├─ .env.example
├─ migrations.sql
├─ public/
│  ├─ index.php      # punto de entrada (router simple)
│  └─ .htaccess      # opcional para Apache
├─ src/
│  ├─ Database.php
│  ├─ Models/
│  │  └─ User.php
│  └─ Controllers/
│     └─ AuthController.php
└─ vendor/

SQL: tabla users

Archivo migrations.sql:

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

.env de ejemplo

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=api_jwt_db
DB_USER=root
DB_PASS=secret
JWT_SECRET=tu_secreto_muy_seguro_aqui
JWT_ISSUER=tu_dominio.local
JWT_EXPIRE=3600

Implementación: archivos principales

1) src/Database.php — Singleton PDO con variables de entorno.

<?php
namespace App;

use Dotenv\Dotenv;
use PDO;
use PDOException;

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

    public static function getConnection(): PDO
    {
        if (self::$instance === null) {
            $dotenv = Dotenv::createImmutable(__DIR__ . '/../');
            $dotenv->safeLoad();

            $host = $_ENV['DB_HOST'] ?? '127.0.0.1';
            $port = $_ENV['DB_PORT'] ?? '3306';
            $db   = $_ENV['DB_NAME'] ?? 'test';
            $user = $_ENV['DB_USER'] ?? 'root';
            $pass = $_ENV['DB_PASS'] ?? '';

            $dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";

            try {
                $pdo = new PDO($dsn, $user, $pass, [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES => false,
                ]);
                self::$instance = $pdo;
            } catch (PDOException $e) {
                http_response_code(500);
                echo json_encode(['error' => 'DB connection failed']);
                exit;
            }
        }

        return self::$instance;
    }
}

Por qué: usar un singleton evita múltiples conexiones. PDO con ERRMODE_EXCEPTION y EMULATE_PREPARES=false mejora seguridad y comportamiento.

2) src/Models/User.php — operaciones sobre usuarios.

<?php
namespace App\Models;

use App\Database;
use PDO;

class User
{
    private PDO $db;

    public function __construct()
    {
        $this->db = Database::getConnection();
    }

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

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

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

Por qué: separar modelo permite pruebas y reutilización. El método create recibe la contraseña ya hasheada.

3) src/Controllers/AuthController.php — lógica de registro, login y verificación del token.

<?php
namespace App\Controllers;

use App\Models\User;
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)
    {
        $email = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
        $password = $data['password'] ?? '';
        $name = isset($data['name']) ? trim($data['name']) : null;

        if (!$email || strlen($password) < 8) {
            http_response_code(422);
            echo json_encode(['error' => 'Invalid input. Email must be valid and password >= 8 chars.']);
            return;
        }

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

        $passwordHash = password_hash($password, PASSWORD_DEFAULT);
        $id = $this->userModel->create($email, $passwordHash, $name);

        http_response_code(201);
        echo json_encode(['id' => $id, 'email' => $email]);
    }

    public function login(array $data)
    {
        $email = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
        $password = $data['password'] ?? '';

        if (!$email || !$password) {
            http_response_code(422);
            echo json_encode(['error' => 'Email and password required']);
            return;
        }

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

        $now = time();
        $expire = (int)($_ENV['JWT_EXPIRE'] ?? 3600);
        $payload = [
            'iat' => $now,
            'exp' => $now + $expire,
            'iss' => ($_ENV['JWT_ISSUER'] ?? 'local'),
            'sub' => (string)$user['id'],
            'email' => $user['email']
        ];

        $jwt = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');

        echo json_encode(['token' => $jwt, 'expires_in' => $expire]);
    }

    public function profile(array $headers)
    {
        $authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
        if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
            http_response_code(401);
            echo json_encode(['error' => 'Missing token']);
            return;
        }

        $token = trim(substr($authHeader, 7));
        try {
            $decoded = JWT::decode($token, new Key($_ENV['JWT_SECRET'], 'HS256'));
            $userId = (int)$decoded->sub;
            $user = $this->userModel->findById($userId);
            if (!$user) {
                http_response_code(404);
                echo json_encode(['error' => 'User not found']);
                return;
            }
            echo json_encode(['user' => $user]);
        } catch (\Exception $e) {
            http_response_code(401);
            echo json_encode(['error' => 'Invalid or expired token']);
        }
    }
}

Por qué: el controlador centraliza validación y respuestas. Usamos password_hash y password_verify. El token contiene sub (user id) y expiración.

4) public/index.php — router simple y punto de entrada.

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

use App\Controllers\AuthController;
use Dotenv\Dotenv;

header('Content-Type: application/json; charset=utf-8');
// CORS básico, ajustar en producción
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->safeLoad();

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

$auth = new AuthController();

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

switch (true) {
    case $path === '/register' && $method === 'POST':
        $auth->register($input);
        break;

    case $path === '/login' && $method === 'POST':
        $auth->login($input);
        break;

    case $path === '/profile' && $method === 'GET':
        // getallheaders() may not be available en algunos SAPIs; usar alternativa
        $headers = function_exists('getallheaders') ? getallheaders() : [];
        $auth->profile($headers);
        break;

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

Por qué: router minimalista para mantener el ejemplo enfocado. En producción usar un micro-framework o un router robusto.

5) public/.htaccess (opcional para Apache)

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

Instalación y ejecución

  1. Clona/crea el proyecto y coloca los archivos según la estructura.
  2. Ejecuta composer install.
  3. Crea la base de datos y ejecuta migrations.sql.
  4. Copia .env.example a .env y ajusta valores.
  5. Inicia el servidor: por ejemplo php -S localhost:8080 -t public.

Pruebas (curl)

Registro:

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"email":"dev@ejemplo.local","password":"Password123","name":"Dev"}'

Login:

curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"dev@ejemplo.local","password":"Password123"}'

# Respuesta: {"token":"...","expires_in":3600}

Acceder a /profile usando el token:

curl http://localhost:8080/profile \
  -H "Authorization: Bearer TU_TOKEN_AQUI"

Explicación de decisiones clave (el porqué)

  • PDO con consultas preparadas: previene inyección SQL y mejora la portabilidad entre DBMS.
  • password_hash / password_verify: uso del algoritmo seguro y adaptativo (bcrypt/argon2 según plataforma).
  • JWT: permite autenticación stateless para APIs. Guardamos solo el id en el token (sub) para evitar exponer datos sensibles.
  • Dotenv: variables sensibles (DB, JWT secret) fuera del repositorio.
  • Token expiración: fundamental para limitar ventana de abuso; usar refresh tokens para sesiones largas.
  • Router simple: suficiente para ejemplo; en producción sustituir por FastRoute, Slim, Lumen o un framework.

Mejoras recomendadas y riesgos a considerar

  • Usa HTTPS obligatorio en producción: los tokens y credenciales viajan en claro sin TLS.
  • Implementa rate limiting en endpoints sensibles (/login, /register).
  • Considera listas de revocación o tokens de refresco si necesitas invalidar tokens antes de que expiren.
  • Almacena el secreto JWT de forma segura (KMS/secret manager) y rota periódicamente.
  • Valida esquema de entrada más estrictamente (ej. usando JSON Schema o validadores).
  • Protege contra fuerza bruta y enumeración de usuarios (respuestas genéricas para credenciales inválidas).

Siguiente paso práctico: integra un endpoint para refrescar tokens usando refresh tokens almacenados en la base de datos (con expiración y revocación). Como advertencia de seguridad: jamás almacenes el JWT en localStorage sin considerar XSS; prefiere HttpOnly cookies si la API es consumida por un navegador y necesitas protección contra XSS.

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