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:
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.
😎