Acostumbro estar pletórico ante la situación de programar en ciertos lenguajes de backend, entre ellos PHP. Recién hice un ejercicio de una API REST con FastifyJS algo completo diría yo, para un escenario básico por supuesto. Ahora regreso con una temática similar pero en este micro framework llamado Lumen, el hijo de Laravel y Symfony, un chistorete por el hecho que Laravel ha usado parte del core de Symfony para “runear” a gusto.

El ejercicio a desarrollar consta de una capa de autorización y autenticación “simple” basado en JWT. Hago énfasis en simple por ser una base para un software real, faltará detallar y escalar dependiendo de las necesidades productivas.

Así que sin más, el código del ejercicio completo lo encuentras aquí.

Iniciar servidor

Para levantar el servidor de Lumen obvio usamos Docker con el siguiente Dockerfile, vamos sobrados para desarrollo:

FROM php:8.1-cli
 
COPY --from=composer:2.3.4 /usr/bin/composer /usr/local/bin/composer
 
RUN apt update && apt install -y \
   nodejs \
   npm
 
RUN docker-php-ext-install mysqli pdo pdo_mysql
 
RUN npm install nodemon -g
 
COPY . /usr/src/lumen
 
WORKDIR /usr/src/lumen
 
RUN composer install --no-scripts
RUN composer dumpautoload --optimize
 
CMD bash -c "php -r \"file_exists('.env') || copy('.env.example', '.env');\" && nodemon --watch /usr/src/lumen --ext php --exec php -S 0.0.0.0:8000 -t ./public"

Lumen 9 precisa usar versión de PHP >= 8, así que el Dockerfile parte de una base 8.1-cli.

También copiamos composer de dockerhub:

COPY --from=composer:2.3.4 /usr/bin/composer /usr/local/bin/composer

De ahí unos paquetes necesarios disponibles con APT:

RUN apt update && apt install -y \
   nodejs \
   npm

Y unas extensiones de php para MySQL:

RUN docker-php-ext-install mysqli pdo pdo_mysql

Dirás para qué carajo queremos Nodejs y npm, estoy seguro que eso dijiste, básicamente quiero tener una instancia de nodemon dentro del contenedor de nuestra app, esto con el objetivo de tener un “livereload” de código cuando ejecutemos cambios en los archivos del proyecto.

Que nodemon este escrito en Javascript, no significa que sea exclusivo para usar en Javascript, ¿Verdad?

Suelo utilizarlo con otras cosas, como script en bash, workers de cualquier lenguaje que necesite un “refresher”. Así que lo instalamos en el contenedor.

RUN npm install nodemon -g

Seguimos con lo de rutina, copiar archivos y buildear los paquetes del proyecto:

COPY . /usr/src/lumen
 
WORKDIR /usr/src/lumen
 
RUN composer install --no-scripts
RUN composer dumpautoload --optimize

Finalizamos la imagen como un ejecutable, por supuesto:

CMD bash -c "php -r \"file_exists('.env') || copy('.env.example', '.env');\" && nodemon --watch /usr/src/lumen --ext php --exec php -S 0.0.0.0:8000 -t ./public"

Un poco largo el comando pero tiene un sentido, generar el .env (archivo de variables de entorno), ejecutar nodemon observando la raíz del proyecto (que es donde vamos a modificar código) y reiniciando (cuando exista un cambio) el servidor integrado de php en el puerto 8080, solo la carpeta public, que según la documentación de laravel:

“El directorio público contiene el archivo index.php, que es el punto de entrada para todas las solicitudes que ingresan a su aplicación y configura la carga automática. Este directorio también alberga sus activos, como imágenes, JavaScript y CSS.”

Docker-compose

version: '3'
 
services:
 
 lumen-jwt-example:
   build:
     context: .
     dockerfile: Dockerfile
   restart: "no"
   tty: true
   ports:
     - '8000:8000'
   volumes:
     - .:/usr/src/lumen
     - vendor:/usr/src/lumen/vendor
   networks:
     - ho-network
 
 lumen-jwt-db:
   image: mysql:8
   restart: "no"
   tty: true
   ports:
     - '3306'
   environment:
     MYSQL_ROOT_PASSWORD: root
     MYSQL_DATABASE: example
   volumes:
     - mysql:/var/lib/mysql/
   networks:
     - ho-network
 
 lumen-jwt-pma:
   restart: "no"
   image: phpmyadmin/phpmyadmin
   ports:
     - '8001:80'
   environment:
     MYSQL_USERNAME: root
     PMA_HOST: lumen-jwt-db
     MYSQL_ROOT_PASSWORD: root
   networks:
     - ho-network
 
networks:
 ho-network:
   driver: bridge
 
volumes:
 vendor:
 mysql:
   driver: local

Tenemos el siguiente docker-compose.yml que consta de tres servicios: el contexto principal que parte del dockerfile anterior, una base de datos relacional llamada MySQL en su versión 8 y un PMA, un software web para la administración de MySQL, básicamente una UI, que si estamos trabajando con PHP y MySQL no puede faltar.

Siempre me gusta predicar el uso de variables de entorno en vez de hardcodear claves en un archivo del repositorio, por ejemplo:

   environment:
     MYSQL_ROOT_PASSWORD: $MYSQL_USER
     MYSQL_DATABASE: $MYSQL_DB_DEFAULT

Para este ejemplo práctico, no hay dramas, así que iniciamos los servicios:

docker-compose up

Puedes visitar localhost:8000 para ir a la URL principal, la URL de PMA es http://localhost:8001/.

Migraciones y seeders

Estás funcionalidades de Laravel nos permiten interactuar con la Base de datos. La primera llamada migrations, nos permite llevar un control y versionado sobre la base de datos con código. La segunda llamada seeders, nos permite ingresar información la BD con registros preestablecidos.

Migrations

Siendo un ejercicio exclusivo de autenticación y autorización, necesitamos un modelo de usuarios, este creará en BD una entidad con la siguiente información:

<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
 
return new class extends Migration
{
   /**
    * Run the migrations.
    *
    * @return void
    */
   public function up()
   {
       Schema::create('users', function (Blueprint $table) {
           $table->id();
           $table->string('firstname');
           $table->string('lastname');
           $table->string('email')->unique();
           $table->string('password');
           $table->rememberToken();
           $table->timestamps();
       });
   }
 
   /**
    * Reverse the migrations.
    *
    * @return void
    */
   public function down()
   {
       Schema::dropIfExists('users');
   }
};

Seeders

Necesitamos cargar información dentro de users con el siguiente seeder:

<?php
 
namespace Database\Seeders;
 
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Support\Facades\Hash;
use Illuminate\Database\Seeder;
use App\Models\User;
 
class UserTableSeeder extends Seeder
{
   /**
    * Run the database seeds.
    *
    * @return void
    */
   public function run()
   {
       $user = User::create([
           'firstname' => 'Cabel',
           'lastname' => 'Escamilla', 
           'email' => 'example@gmail.com',
           'password' => Hash::make('password')
       ]);
   }
}

Nada complejo, solo llamamos al modelo y creamos un nuevo registro con datos en hardcode.

Ejecutando el siguiente comando, podrás cargar las migraciones y los seeders la primera vez o hacer un reset, si es el caso:

docker exec -it  $(docker ps --format "{{.Names}}" |  grep "lumen-jwt-example_${PWD##*/}") php artisan migrate:refresh --seed
  • “lumen-jwt-example_” viene de docker-compose.yml, necesitas tener levantados los servicios para que acceda al container.

Visita http://localhost:8001/ para ir a:

la terminal

Las claves de acceso las habrás visto en el archivo docker-compose.yml:

usuario: "root"
contraseña: "root"

Sin comillas, obviamente.

Modelo, Vistas, Controladores

Lumen/Laravel nos invita a utilizar el patrón de arquitectura MVC para desarrollar nuestros módulos de la aplicación, este ejercicio al ser una API, no posee una capa de view, así que solo nos enfocaremos en modelos y controladores.

Model

El archivo app/Models/User.php se encarga de mapear la información de la BD junto con Eloquent que igual es un mundo de características y funcionalidades de este ORM.

<?php
 
namespace App\Models;
 
use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Lumen\Auth\Authorizable;
use Tymon\JWTAuth\Contracts\JWTSubject;
 
class User extends Model implements JWTSubject, AuthenticatableContract, AuthorizableContract
{
   use Authenticatable, Authorizable, HasFactory;
 
   /**
    * The attributes that are mass assignable.
    *
    * @var string[]
    */
   protected $fillable = [
       'firstname', 'lastname', 'email', 'password'
   ];
 
   /**
    * The attributes excluded from the model's JSON form.
    *
    * @var string[]
    */
   protected $hidden = [
       'password',
   ];
 
   // Rest omitted for brevity
 
   /**
    * Get the identifier that will be stored in the subject claim of the JWT.
    *
    * @return mixed
    */
   public function getJWTIdentifier()
   {
       return $this->getKey();
   }
 
   /**
    * Return a key value array, containing any custom claims to be added to the JWT.
    *
    * @return array
    */
   public function getJWTCustomClaims()
   {
       return [];
   }
}
 

Posee unos métodos que serán llamados por los controladores, con funcionalidades muy específicas.

// Definimos como hidden el atributo password:

   protected $hidden = [
       'password',
   ];

Por obvias razones no deseamos exponer contraseñas o hashes.

Controller

El controlador app/Http/Controllers/AuthController.php es el siguiente:

<?php
 
namespace App\Http\Controllers;
 
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
 
class AuthController extends Controller
{
   /**
    * Create a new controller instance.
    *
    * @return void
    */
   public function __construct()
   {
       $this->middleware('auth:api', ['except' => ['login']]);
   }
 
   /**
    * Login with credentials.
    *
    * @param  Request  $request
    * @return Response
    */
   public function login(Request $request)
   {
 
       $this->validate($request, [
           'email' => 'required|string',
           'password' => 'required|string',
       ]);
 
       $credentials = $request->only(['email', 'password']);
 
       if (!$token = Auth::attempt($credentials)) {
           return response()->json(['message' => 'Unauthorized user'], 401);
       }
 
       return $this->responseToken($token);
   }
 
   /**
    * Refresh token.
    *
    * @return \Illuminate\Http\JsonResponse
    */
   public function refresh()
   {
       return $this->responseToken(auth()->refresh());
   }
 
   /**
    * Get user
    *
    * @return \Illuminate\Http\JsonResponse
    */
   public function getUser()
   {
       return response()->json(auth()->user());
   }
 
   /**
    * Get response data token.
    *
    * @param  string $token
    *
    * @return \Illuminate\Http\JsonResponse
    */
   protected function responseToken($token)
   {
       return response()->json([
           'access_token' => $token,
           'token_type' => 'bearer',
           'user' => auth()->user(),
           'expires_in' => auth()->factory()->getTTL() * 60 * 8
       ]);
   }
}

Dentro de las acciones registradas tenemos:

login: Autenticación.
refresh: Actualizar token.
getUser: Obtener la información del usuario.
responseToken: Imprimir la información del token. 

Este controlador tiene un middleware de Auth, el paquete jwt-auth con integración para Laravel, se encarga de otorgar autorización a estos recursos mediante un token JWT.

public function __construct()
{
    $this->middleware('auth:api', ['except' => ['login']]);
}

Router

Las acciones que se definieron en el controlador, las exponemos en las rutas siguientes:

/*
|--------------------------------------------------------------------------
| Application Routes
|--------------------------------------------------------------------------
|
| Here is where you can register all of the routes for an application.
| It is a breeze. Simply tell Lumen the URIs it should respond to
| and give it the Closure to call when that URI is requested.
|
*/
 
$router->get('/', function () use ($router) {
   return response()->json([
       "version" => $router->app->version(),
       "description" => "example Lumen JWT",
   ]);
});
 
Route::group([
   'prefix' => 'api',
], function ($router) {
   Route::post('login', 'AuthController@login');
   Route::post('refresh', 'AuthController@refresh');
   Route::get('user', 'AuthController@getUser');
});

Esto en el archivo routes/web.php.

Pruebas

Ya definidos los recursos vamos a ir testeando la API en Postman con nuestro usuario hardcodeado:

email: example@gmail.com
password: password

Autenticación

Method / URL

POST / http://localhost:8000/api/login

Body

{
   "email": "example@gmail.com",
   "password": "password"
}

Response

{
   "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAvYXBpL2xvZ2luIiwiaWF0IjoxNjYyNTAxMjY0LCJleHAiOjE2NjI1MDQ4NjQsIm5iZiI6MTY2MjUwMTI2NCwianRpIjoia2hKeXRNU0FGYmxmTEt2aiIsInN1YiI6IjEiLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.ONSuXubIALPZvlSAKASFXuGq-W3Ys0eZUnSemEs1MUM",
   "token_type": "bearer",
   "user": {
       "id": 1,
       "firstname": "Cabel",
       "lastname": "Escamilla",
       "email": "example@gmail.com",
       "remember_token": null,
       "created_at": "2022-09-06T01:59:56.000000Z",
       "updated_at": "2022-09-06T01:59:56.000000Z"
   },
   "expires_in": 28800
}

Autorización

Si deseamos solicitar el siguiente recurso mediante el método GET:

Method / URL

GET / http://localhost:8000/api/user

Obtendremos la siguiente respuesta:

{
   "message": "Unauthorized"
}

Seguro sabes lo que falta, el token de autorización en las cabeceras de la petición. El atributo access_token que nos devolvió el recurso anterior, debemos incluirlo en la pestaña Authorization de Postman.

Con esto ya obtenemos la respuesta correcta:

{
   "id": 1,
   "firstname": "Cabel",
   "lastname": "Escamilla",
   "email": "example@gmail.com",
   "remember_token": null,
   "created_at": "2022-09-06T01:59:56.000000Z",
   "updated_at": "2022-09-06T01:59:56.000000Z"
}

Existe un recurso más:

Refresh token

Es común dar un tiempo de expiración a los token, es más seguro en el diseño REST , pero requiere un poco de esfuerzo extra en la implementación.

Method / URL

POST / http://localhost:8000/api/refresh

Response

{
   "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAvYXBpL3JlZnJlc2giLCJpYXQiOjE2NjI1MDEyNjQsImV4cCI6MTY2MjUwNTQzMywibmJmIjoxNjYyNTAxODMzLCJqdGkiOiJkSUF3V1c5cmxhcGtVcVZwIiwic3ViIjoiMSIsInBydiI6IjIzYmQ1Yzg5NDlmNjAwYWRiMzllNzAxYzQwMDg3MmRiN2E1OTc2ZjcifQ._mJFnhNN_KcAuoZg7zjjGzeWDuLBFQCOMrTBq9FdFAU",
   "token_type": "bearer",
   "user": {
       "id": 1,
       "firstname": "Cabel",
       "lastname": "Escamilla",
       "email": "example@gmail.com",
       "remember_token": null,
       "created_at": "2022-09-06T01:59:56.000000Z",
       "updated_at": "2022-09-06T01:59:56.000000Z"
   },
   "expires_in": 28800
}

Con esto, la API identificará el usuario del token proveniente de las cabeceras (Es necesario incluirlo en Postman en la pestaña Authorization), genera un nuevo token y el anterior lo elimina, es decir si se desea utilizar el anterior, obtendremos un rotundo:

{
   "message": "Unauthorized"
}

Esto es algo super básico en una API de Auth, incluso puedes programar un propio API Gateway para controlar los acesos entre aplicaciones.

😎