Proyecto: Sistema de autenticación seguro en PHP con JWT y PDO

php Proyecto: Sistema de autenticación seguro en PHP con JWT y PDO

Proyecto práctico: Sistema de autenticación seguro en PHP con JWT y PDO

Voy a guiarte para crear una API pequeña en PHP que implemente registro, login, refresh token y acceso a recursos protegidos usando JWT (stateless) y PDO (consultas preparadas). El enfoque prioriza seguridad práctica: password_hash(), prepared statements, tokens de refresco almacenados en BD y cookies seguras.

Requisitos previos

  • PHP 8.0+ (extensiones: pdo_mysql, openssl)
  • Composer
  • MySQL o MariaDB
  • Servidor web (Apache/Nginx) apuntando a la carpeta public/

Características

  • Registro de usuario con password_hash
  • Login que genera access token (JWT) y refresh token (largo, almacenado en BD)
  • Endpoint /profile protegido por JWT
  • Refresh token endpoint
  • Uso de PDO y prepared statements

Estructura de carpetas


project/
├─ public/
│  └─ index.php         # Front controller (router simple)
├─ src/
│  ├─ Database.php      # Conexión PDO
│  ├─ Auth.php          # Lógica de tokens y auth
│  └─ UserController.php# Endpoints
├─ migrations/
│  └─ schema.sql
├─ .env.example
├─ composer.json
└─ README.md

.env.example


DB_HOST=127.0.0.1
DB_NAME=auth_example
DB_USER=root
DB_PASS=secret
JWT_SECRET=change_this_to_long_random_value
ACCESS_EXPIRE=900        # 15 minutos
REFRESH_EXPIRE=604800   # 7 días

Migración SQL (migrations/schema.sql)


CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  refresh_token VARCHAR(512),
  refresh_expires_at DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

composer.json

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

public/index.php

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

use App\UserController;
use Dotenv\Dotenv;

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

$controller = new UserController();

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

// Simple router
if ($method === 'POST' && $path === '/register') {
    $controller->register();
    exit;
}
if ($method === 'POST' && $path === '/login') {
    $controller->login();
    exit;
}
if ($method === 'POST' && $path === '/refresh') {
    $controller->refresh();
    exit;
}
if ($method === 'GET' && $path === '/profile') {
    $controller->profile();
    exit;
}

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

src/Database.php

<?php
namespace App;

class Database
{
    private static $pdo;

    public static function get()
    {
        if (self::$pdo) return self::$pdo;

        $host = $_ENV['DB_HOST'];
        $db = $_ENV['DB_NAME'];
        $user = $_ENV['DB_USER'];
        $pass = $_ENV['DB_PASS'];
        $dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";
        $opts = [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
            \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
        ];

        self::$pdo = new \PDO($dsn, $user, $pass, $opts);
        return self::$pdo;
    }
}

src/Auth.php

<?php
namespace App;

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

class Auth
{
    public static function hashPassword(string $pwd): string
    {
        return password_hash($pwd, PASSWORD_DEFAULT);
    }

    public static function verifyPassword(string $pwd, string $hash): bool
    {
        return password_verify($pwd, $hash);
    }

    public static function generateAccessToken(array $payload): string
    {
        $secret = $_ENV['JWT_SECRET'];
        $now = time();
        $exp = $now + intval($_ENV['ACCESS_EXPIRE']);

        $token = array_merge($payload, [
            'iat' => $now,
            'exp' => $exp
        ]);

        return JWT::encode($token, $secret, 'HS256');
    }

    public static function verifyAccessToken(string $jwt)
    {
        try {
            $secret = $_ENV['JWT_SECRET'];
            return JWT::decode($jwt, new Key($secret, 'HS256'));
        } catch (\Exception $e) {
            return null;
        }
    }

    public static function generateRefreshToken(): string
    {
        return bin2hex(random_bytes(64));
    }
}

src/UserController.php

<?php
namespace App;

class UserController
{
    private $pdo;

    public function __construct()
    {
        $this->pdo = Database::get();
        header('Content-Type: application/json; charset=utf-8');
    }

    private function jsonInput()
    {
        $raw = file_get_contents('php://input');
        return json_decode($raw, true) ?? [];
    }

    public function register()
    {
        $data = $this->jsonInput();
        $email = $data['email'] ?? '';
        $password = $data['password'] ?? '';

        if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid input']);
            return;
        }

        $hash = Auth::hashPassword($password);

        $stmt = $this->pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:e, :p)');
        try {
            $stmt->execute([':e' => $email, ':p' => $hash]);
            http_response_code(201);
            echo json_encode(['message' => 'User created']);
        } catch (\PDOException $e) {
            http_response_code(409);
            echo json_encode(['error' => 'User exists']);
        }
    }

    public function login()
    {
        $data = $this->jsonInput();
        $email = $data['email'] ?? '';
        $password = $data['password'] ?? '';

        $stmt = $this->pdo->prepare('SELECT id, password_hash FROM users WHERE email = :e');
        $stmt->execute([':e' => $email]);
        $user = $stmt->fetch();

        if (!$user || !Auth::verifyPassword($password, $user['password_hash'])) {
            http_response_code(401);
            echo json_encode(['error' => 'Invalid credentials']);
            return;
        }

        $access = Auth::generateAccessToken(['sub' => $user['id'], 'email' => $email]);
        $refresh = Auth::generateRefreshToken();
        $expiresAt = date('Y-m-d H:i:s', time() + intval($_ENV['REFRESH_EXPIRE']));

        $upd = $this->pdo->prepare('UPDATE users SET refresh_token = :r, refresh_expires_at = :e WHERE id = :id');
        $upd->execute([':r' => $refresh, ':e' => $expiresAt, ':id' => $user['id']]);

        // Return tokens: access in body, refresh as HttpOnly cookie
        setcookie('refresh_token', $refresh, [
            'expires' => time() + intval($_ENV['REFRESH_EXPIRE']),
            'httponly' => true,
            'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
            'samesite' => 'Lax'
        ]);

        echo json_encode(['access_token' => $access, 'token_type' => 'bearer', 'expires_in' => intval($_ENV['ACCESS_EXPIRE'])]);
    }

    public function refresh()
    {
        // Read refresh from cookie
        $refresh = $_COOKIE['refresh_token'] ?? null;
        if (!$refresh) {
            http_response_code(401);
            echo json_encode(['error' => 'No refresh token']);
            return;
        }

        $stmt = $this->pdo->prepare('SELECT id, email, refresh_expires_at FROM users WHERE refresh_token = :r');
        $stmt->execute([':r' => $refresh]);
        $user = $stmt->fetch();

        if (!$user || strtotime($user['refresh_expires_at']) <= time()) {
            http_response_code(401);
            echo json_encode(['error' => 'Invalid refresh token']);
            return;
        }

        // Issue new access token and rotate refresh token
        $access = Auth::generateAccessToken(['sub' => $user['id'], 'email' => $user['email']]);
        $newRefresh = Auth::generateRefreshToken();
        $expiresAt = date('Y-m-d H:i:s', time() + intval($_ENV['REFRESH_EXPIRE']));

        $upd = $this->pdo->prepare('UPDATE users SET refresh_token = :r, refresh_expires_at = :e WHERE id = :id');
        $upd->execute([':r' => $newRefresh, ':e' => $expiresAt, ':id' => $user['id']]);

        setcookie('refresh_token', $newRefresh, [
            'expires' => time() + intval($_ENV['REFRESH_EXPIRE']),
            'httponly' => true,
            'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
            'samesite' => 'Lax'
        ]);

        echo json_encode(['access_token' => $access, 'expires_in' => intval($_ENV['ACCESS_EXPIRE'])]);
    }

    public function profile()
    {
        $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (!preg_match('/Bearer\s+(\S+)/', $auth, $m)) {
            http_response_code(401);
            echo json_encode(['error' => 'Token required']);
            return;
        }

        $jwt = $m[1];
        $data = Auth::verifyAccessToken($jwt);
        if (!$data) {
            http_response_code(401);
            echo json_encode(['error' => 'Invalid token']);
            return;
        }

        // Normally you'd query DB for user details; we'll return the token payload
        echo json_encode(['user' => ['id' => $data->sub, 'email' => $data->email]]);
    }
}

Por qué está diseñado así

  • PDO con prepared statements evita inyecciones SQL.
  • password_hash/verify delegan al algoritmo seguro de PHP (bcrypt/argon2 si está disponible).
  • JWT como access token hace el acceso stateless y rápido; corto periodo de validez reduce impacto si se filtra.
  • Refresh token: largo, almacenado en BD y rotado al usarlo. Si se roba el cookie, el token puede revocarse en servidor (limpiar campo).
  • Se usa cookie HttpOnly para refresh token: más difícil de robar vía XSS. El access token se envía en Authorization para APIs.

Notas prácticas y despliegue

  • Siempre servir por HTTPS. Cookies marcadas 'secure' solo se envían por TLS.
  • Guarda JWT_SECRET en un vault/secret manager en producción; debe ser largo y aleatorio.
  • Implementa rate limiting y bloqueo temporal tras varios intentos de login para evitar fuerza bruta.
  • Considera usar SameSite=strict si tu front y API están en el mismo dominio y no necesitas navegación cross-site.

Con esto tienes un sistema minimal viable y seguro para autenticación en PHP. Como siguiente paso, añade invalidación de refresh tokens al hacer logout (limpiar campo en BD), lleva un historial de sesiones para poder revocar sesiones específicas y habilita logging y alertas sobre intentos sospechosos.

Consejo avanzado: implementa rotación de refresh tokens + lista de revocación con versión o jti en la tabla; así puedes invalidar tokens sin afectar otras sesiones. Y recuerda: nunca confíes en tokens si no están firmados y validados correctamente; registra y monitoriza intentos de abuso.

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