Laravel Fortify, qué es, cuando usar y primeros pasos para crear un sistema de autenticación flexible

Video thumbnail

Índice de contenido

Laravel Fortify es un paquete para la autenticación, registro, recuperación de contraseña, verificación de correo electrónico y más, en pocas palabras, permite realizar las mismas funcionalidades que Laravel Breeze que empleamos antes, pero la diferencia radica en que no es tan intrusivo, cuando instalamos Laravel Breeze el mismo instala Tailwind.css y género varios componentes, controladores, vistas y rutas asociadas.

En el caso de Laravel Fortify no es así y nos provee las mismas características pero sin necesidad de la interfaz gráfica, por lo tanto, es particularmente útil cuidando quieres desarrollar un backend de autenticación más personalizado que el que nos ofrece Breeze; es importante señalar a que si estás empleando Laravel Breeze o alguna solución similar no es necesario utilizar Laravel Fortify.

Otra posible comparación que pueda que estés realizando es con Sanctum, Laravel Fortify y Laravel Sanctum no son paquetes mutuamente excluyentes ni competidores si no, se pueden emplear en un mismo proyecto en caso de que se lo requiere, Laravel Sanctum solo se ocupa de administrar tokens API y autenticar a los usuarios existentes mediante cookies o tokens de sesión. 

Sanctum no proporciona ninguna ruta que maneje el registro de usuarios, el restablecimiento de contraseñas, etc, en pocas palabras Sanctum está enfocado en la autenticación de una Api Rest y Laravel Fortify para una aplicación web tradicional.

Anteriormente vimos como usar la IA en un proyecto en Laravel construyendo nuestro propio ChatGTP.

Laravel Fortify: qué es, cuándo usarlo y cómo crear un sistema de autenticación flexible en Laravel

Cuando estás construyendo una aplicación con Laravel y llega el momento de implementar la autenticación, normalmente aparecen los mismos nombres: Breeze, Jetstream, Sanctum… y, si necesitas algo más flexible, Laravel Fortify.

En mi caso, Fortify empezó a tener sentido cuando necesitaba control total sobre el frontend, sin que el framework me impusiera vistas, estilos o componentes que luego iba a terminar desmontando. Si alguna vez has instalado Breeze “solo para probar” y después has pasado más tiempo borrando código que usándolo, probablemente entiendas por qué Fortify existe.

En este artículo te explico qué es Laravel Fortify, cuándo usarlo (y cuándo no), cómo configurarlo correctamente y qué problemas reales suelen aparecer cuando lo llevas a producción, especialmente en APIs y SPAs.

¿Qué es Laravel Fortify y por qué es “headless”?

Laravel Fortify es un backend de autenticación sin interfaz gráfica.
Cuando se dice que es headless, significa exactamente esto: Fortify se encarga de toda la lógica de autenticación, pero no incluye vistas, estilos ni componentes de UI.

Con Fortify tienes, entre otras cosas:

  • Login y logout
  • Registro de usuarios
  • Recuperación de contraseña
  • Verificación de email
  • Confirmación de contraseña
  • Rate limiting
  • Autenticación en dos pasos (2FA)

Todo eso ya implementado, probado y mantenido por el equipo de Laravel… pero sin decidir por ti cómo debe verse el formulario de login.

Qué significa realmente que Fortify no tenga frontend

Esto es clave:

Fortify sí define rutas y controladores, pero no define vistas. Si intentas acceder a /login sin haber configurado nada, obtendrás un error del tipo:

Target [Laravel\Fortify\Contracts\LoginViewResponse] is not instantiable.

No es un bug. Es Fortify diciéndote: “dime tú cómo quieres que sea tu UI”.

Esto es exactamente lo que lo hace tan potente cuando trabajas con:

  • Blade personalizado
  • Vue / React
  • SPAs
  • APIs puras

¿Cuándo tiene sentido usar Laravel Fortify (y cuándo no)?

Aquí es donde mucha gente se equivoca.

Casos en los que Fortify encaja mejor que Breeze

  • Laravel Fortify tiene todo el sentido cuando:
  • Quieres control total del frontend
  • No quieres Tailwind, componentes ni scaffolding automático
  • Estás construyendo una SPA o una API
  • Necesitas personalizar flujos de autenticación
  • Te importa la mantenibilidad a medio/largo plazo

En proyectos reales, Fortify me ha resultado especialmente útil cuando el backend y el frontend evolucionan de forma independiente.

Situaciones donde Fortify no aporta ventajas

Fortify no es la mejor opción si:

  • Quieres algo rápido y visual desde el minuto uno
  • Estás prototipando
  • No te importa el frontend por defecto
  • Tu app es pequeña y no va a crecer

En esos casos, Laravel Breeze es más que suficiente y probablemente más productivo.

Laravel Fortify vs Breeze vs Jetstream

Diferencias en arquitectura y control del frontend

  • Breeze
    • Autenticación + vistas + Tailwind + controladores listos para usar.
      Muy cómodo, pero intrusivo.
  • Jetstream
    • Más completo aún (teams, perfiles, etc.).
      Ideal para apps grandes, pero opinionado, ya no te recomiendo usar Jetstream al ya no ser la oficial.
  • Fortify
    • Solo backend. Tú decides todo lo demás.

Yo suelo resumirlo así:
Breeze y Jetstream te dan una casa amueblada; Fortify te da los cimientos.

Nivel de intrusión y mantenimiento

Uno de los problemas de Breeze es que, cuando el proyecto crece, acabas peleándote con el scaffolding. Con Fortify, eso no pasa: solo consumes endpoints y lógica.

Laravel Fortify y Laravel Sanctum: cómo se complementan

Este es un punto donde hay mucha confusión.

Laravel Fortify y Laravel Sanctum no compiten.

  • Fortify:
    • Autenticación web (login, registro, reset, 2FA…)
  • Sanctum:
    • Autenticación de APIs (cookies, tokens)

En muchos proyectos reales he usado Fortify + Sanctum juntos:

  • Fortify para gestionar usuarios
  • Sanctum para autenticar peticiones API

Si estás construyendo una SPA, esta combinación es prácticamente estándar.

Instalación y configuración inicial de Laravel Fortify

Para instalar Laravel Fortify empleamos el siguiente comando:

$ composer require laravel/fortify

El siguiente paso es ejecutar el comando de instalación:

$ php artisan fortify:install

Este comando generará las migraciones, archivo de configuración, provide entre otros.

Ejecutamos las migraciones:

$ php artisan migrate

Esto genera:

  • Migraciones
  • config/fortify.php
  • FortifyServiceProvider
  • Actions para login, registro, etc.

A partir de aquí, Fortify ya funciona… aunque todavía no “se ve”.

Y con esto, ya podemos emplear Laravel Fortify.

Configuración de features en Laravel Fortify

Una de las cosas que más me gusta de Fortify es que puedes activar solo lo que necesitas mediante características que podemos habilitar o deshabilitar a gusto:

config\fortify.php

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::emailVerification(),
],

Si no quieres verificación por email, simplemente la comentas.
Este enfoque modular hace que Fortify se adapte muy bien a proyectos reales, donde no siempre necesitas todo desde el principio.

Las mismas permite des/habilitar la opción de registro, reiniciar la contraseña y verificación por emails respectivamente, en caso de que no quieras emplear alguna o varias de estas opciones, simplemente la debes de comentar, por ejemplo, si quieres desactivar la verificación por email:

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    // Features::emailVerification(),
],

Usar Laravel Fortify sin vistas: control total del frontend

Si visitas /login o /register sin configurar nada, Fortify falla a propósito. Eso te obliga a decidir:

  • ¿Blade?
  • ¿Vue?
  • ¿React?
  • ¿SPA externa?

Cuando necesito Blade simple, suelo definir las vistas en el FortifyServiceProvider:

Fortify::loginView(function () {
   return view('auth.login');
});

Lo mismo para el registro. Fortify no impone nada, solo espera que tú lo hagas.

Usar Laravel Fortify en APIs y aplicaciones SPA

Desactivar vistas en un backend puro

Si Fortify se usa solo como backend:

'views' => false,

Esto evita comportamientos pensados para aplicaciones web tradicionales.

Redirecciones que conviene modificar

Uno de los problemas más comunes que me encontré es la redirección automática a /home cuando un usuario ya está autenticado.

Para una API, esto no tiene ningún sentido.

La solución pasa por adaptar middlewares como RedirectIfAuthenticated para devolver JSON cuando la request es AJAX.

Middlewares de Fortify que debes entender (y ajustar)

RedirectIfAuthenticated

Por defecto, redirige. En APIs, esto suele romper flujos.

Detectar $request->expectsJson() y devolver una respuesta clara evita muchos dolores de cabeza.

Authenticate middleware

Otro ajuste típico es redirigir al frontend correcto cuando el usuario no está autenticado, algo fundamental cuando backend y frontend viven en dominios distintos.

Problemas comunes al usar Laravel Fortify (y cómo solucionarlos)

Error 401 aun estando autenticado

Muy habitual cuando trabajas con Sanctum. Normalmente se debe a:

  • SANCTUM_STATEFUL_DOMAINS mal configurado
  • SESSION_DOMAIN incorrecto o innecesario

En más de un proyecto, el problema era simplemente haber incluido http:// donde no tocaba.

Errores de CORS

Otro clásico.

Revisa siempre:

  • config/cors.php
  • allowed_origins
  • rutas incluidas en paths

Personalización avanzada de Laravel Fortify

Fortify permite ir mucho más allá:

  • Rate limiting personalizado
  • Cambiar username por email o teléfono
  • Flujos de autenticación propios
  • 2FA con TOTP o incluso SMS

He tenido casos donde el 2FA por defecto no encajaba en UX, y Fortify permite sobrescribir prácticamente todo sin hacks.

Ejemplo de uso y funcionamiento

Para ejemplificar su uso, si hacemos un:

$ php artisan r:l

Veremos todas las rutas generadas por Fortify:

GET|HEAD  register ............................................ register › Laravel\Fortify › RegisteredUserController@create
  POST      register ........................................................ Laravel\Fortify › RegisteredUserController@store
  POST      reset-password ................................... password.update › Laravel\Fortify › NewPasswordController@store
  GET|HEAD  reset-password/{token} ........................... password.reset › Laravel\Fortify › NewPasswordController@create
  GET|HEAD  two-factor-challenge ......... two-factor.login › Laravel\Fortify › TwoFactorAuthenticatedSessionController@create
  POST      two-factor-challenge ............................. Laravel\Fortify › TwoFactorAuthenticatedSessionController@store
  GET|HEAD  up ............................................................................................................... 
  GET|HEAD  user/confirm-password ....................................... Laravel\Fortify › ConfirmablePasswordController@show
  POST      user/confirm-password ................... password.confirm › Laravel\Fortify › ConfirmablePasswordController@store
  GET|HEAD  user/confirmed-password-status .. password.confirmation › Laravel\Fortify › ConfirmedPasswordStatusController@show
  POST      user/confirmed-two-factor-authentication two-factor.confirm › Laravel\Fortify › ConfirmedTwoFactorAuthenticationC…  
  PUT       user/password ................................. user-password.update › Laravel\Fortify › PasswordController@update  
  PUT       user/profile-information . user-profile-information.update › Laravel\Fortify › ProfileInformationController@update  
  POST      user/two-factor-authentication ..... two-factor.enable › Laravel\Fortify › TwoFactorAuthenticationController@store  
  DELETE    user/two-factor-authentication .. two-factor.disable › Laravel\Fortify › TwoFactorAuthenticationController@destroy  
  GET|HEAD  user/two-factor-qr-code .................... two-factor.qr-code › Laravel\Fortify › TwoFactorQrCodeController@show
  GET|HEAD  user/two-factor-recovery-codes ........ two-factor.recovery-codes › Laravel\Fortify › RecoveryCodeController@index
  POST      user/two-factor-recovery-codes .................................... Laravel\Fortify › RecoveryCodeController@store
  GET|HEAD  user/two-factor-secret-key ........... two-factor.secret-key › Laravel\Fortify › TwoFactorSecretKeyController@show

Si ingresamos a la de login, veremos un error como el siguiente:

http://larafirstepspackages.test/login

Target [Laravel\Fortify\Contracts\LoginViewResponse] is not instantiable.

Ya que, como comentamos antes, no contamos con páginas o pantallas listas como en Breeze sí no, debemos de crearlas de manera manual (o usarlas con otras tecnologías como consumir estas rutas mediante una app en Vue).

Si comentas el módulo de registrar:

config\fortify.php

// Features::registration(),

E intentas ir a la ruta:

http://larafirstepspackages.test/register

Verás que devuelve una página de 404 ya que, acabamos de deshabilitar la acción para registrar usuarios.

Si exploras el objeto:

Fortify

Veremos muchas opciones que podemos personalizar de Fortify; si revisamos el archivo de:

app\Providers\FortifyServiceProvider.php

Veremos todas las acciones de Fortify que podemos personalizar, como el tiempo de bloqueo tras logins fallidos:

RateLimiter::for('login', function (Request $request) {
   ***

Login

Por ejemplo, podemos especificar la vista para el login:

class FortifyServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }


    public function boot(): void
    {
        ***
        Fortify::loginView(function(){
            return view('auth.login');
        });
    }
}

Y su vista:

resources\views\auth\login.blade.php

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>

<body>

    @if ($errors->any())
        @foreach ($errors->all() as $e)
            <div>
                {{ $e }}
            </div>
        @endforeach
    @endif

    <form action="" method="post">
        @csrf
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="submit" value="Send">
    </form>
</body>

</html>

Registrar

O para registrarse:

class FortifyServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        ***
        Fortify::registerView(function(){
            return view('auth.register');
        });
    }
}

Creemos una vista muy sencilla como las siguiente:

resources\views\auth\register.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    @if ($errors->any())
        @foreach ($errors->all() as $e)
            <div>
                {{ $e }}
            </div>
        @endforeach
    @endif
    <form action="" method="post">
        @csrf
        <input type="text" name="name" placeholder="name">
        <input type="email" name="email">
        <input type="password" name="password" >
        <input type="submit" value="Send">
    </form>
</body>
</html>

Al ingresar a las rutas correspondientes:

http://larafirstepspackages.test/login

http://larafirstepspackages.test/register

Verás el sistema completo para el login y registrarse provisto por Fortify; estas son solamente algunas acciones que tenemos disponibles, explorar su su uso mediante los ejemplo anteriores para que se entienda de manera práctica el funcionamiento del paquete.

Ya con esto, queda por parte del lector explorar el resto de las funcionalidades para que puedas emplearlos en tus proyectos que requieran emplear Fortify para crear un sistema de autenticación completamente personalizado en vez de Breeze:

https://laravel.com/docs/master/fortify

Preguntas frecuentes sobre Laravel Fortify

  • ¿Laravel Fortify incluye frontend?
    • No. Es completamente headless.
  • ¿Se puede usar Fortify con Vue o React?
    • Sí, y es uno de sus mejores casos de uso.
  • ¿Fortify reemplaza a Sanctum?
    • No. Se complementan.
  • ¿Es buena idea usar Fortify en proyectos pequeños?
    • Solo si sabes que el proyecto va a crecer o necesita personalización.

Conclusión: por qué Laravel Fortify es ideal para autenticación personalizada

Laravel Fortify no es para todos los proyectos, pero cuando encaja, encaja muy bien.

Si necesitas:

  • Flexibilidad
  • Control
  • Backend limpio
  • Integración con SPAs o APIs

Fortify es una de las mejores decisiones que puedes tomar en Laravel.

No te da una UI bonita, pero te da algo más importante: libertad.

Autenticación mediante Códigos QR y Sincronización Móvil-Web - Laravel + Flutter

Video thumbnail

La autenticación basada en códigos QR permite transferir una sesión activa y autenticada desde un dispositivo móvil hacia un navegador web de forma instantánea. Este mecanismo, popularizado por aplicaciones como WhatsApp Web, se apoya en una arquitectura híbrida donde el backend actúa como el coordinador de estado entre dos clientes completamente independientes: el navegador web (cliente invitado) y la aplicación móvil nativa (cliente autenticado).

Este es un desarrollo que hice mediante una IA, y le solicité a un Chat IA que me diera un prompt para lograr la autenticación mediante QR empleando Laravel Fortify, este fue el resultado:

Actúa como un desarrollador Senior en Laravel 11/12 y experto en seguridad. Quiero implementar un sistema de autenticación por código QR tipo "WhatsApp Web" optimizado, REUTILIZANDO la tabla y el modelo `LoginToken` que ya tengo para el flujo de 6 caracteres.

El flujo simplificado funciona así:
1. El usuario no autenticado entra a la web y ve un código QR que contiene el token de 6 caracteres numéricos. El token se guarda en la BD con estado 'pending' y sin 'user_id' asignado aún.
2. La página web hace Polling (peticiones GET cada 5 segundos mediante Fetch/AJAX) a un endpoint pasando este token de 6 caracteres. El polling dura un máximo de 1 minuto (60 segundos).
3. Una app móvil (autenticada mediante Sanctum) escanea el QR, lee los 6 caracteres y los envía a un endpoint de la API. El backend busca ese token activo, lo cambia a estado 'approved' y le asigna el 'user_id' del usuario del móvil.
4. En el siguiente polling de la web, el servidor detecta el estado 'approved', loguea al usuario en la sesión web tradicional usando `Auth::loginUsingId($userId)` y redirecciona automáticamente.

Genera el siguiente código completo de inicio a fin (sin resúmenes ni placeholders):

1. MODIFICACIÓN DE MIGRACIÓN Y MODELO:
- Indica cómo agregar el campo `status` (string, por defecto 'pending') a la tabla existente `login_tokens`, manteniendo `user_id` como nullable.
- Actualiza el modelo `LoginToken` incluyendo un método estático `generateForQr(): self`. Este método debe incluir un recolector de basura que elimine permanentemente CUALQUIER token antiguo de la tabla cuya antigüedad (`created_at`) sea mayor a 5 minutos (`now()->subMinutes(5)`) antes de crear el nuevo token aleatorio de 6 dígitos.

2. CONTROLADOR WEB (`QrAuthController`):
- Método para generar el token QR pendiente y retornar la vista.
- Método `checkStatus(Request $request)` que reciba el token por parámetro. Si está expirado o no existe, devuelve JSON con status 'expired'. Si está 'approved', inicia la sesión web tradicional, regenera la sesión, elimina el token de la BD y devuelve JSON con `redirect: true` y la URL de redirección. Si sigue pendiente, devuelve `redirect: false`.

3. VISTA BLADE Y SCRIPT DE POLLING (`auth/login-qr.blade.php`):
- Una vista limpia con Bootstrap 5 que use una librería por CDN (como qrcode.min.js) para pintar el QR usando el string de 6 caracteres.
- Incluye un script nativo (o Alpine.js) con un `setInterval` cada 5 segundos. Debe llevar un contador: al llegar a 1 minuto (12 ejecuciones / 60 segundos), debe detener el polling, ocultar el QR por seguridad y mostrar un botón para refrescar/regenerar el QR.

4. ENDPOINT PARA LA API DE LA APP MÓVIL (`Api/QrVerifyController.php`):
- Un método `approveQr(Request $request)` protegido por el middleware `auth:sanctum`.
- Debe validar que el token de 6 caracteres exista, esté 'pending' y no haya expirado. Si es válido, actualiza el registro cambiando el `status` a 'approved' y asociando el `user_id` del usuario autenticado en la API (`$request->user()->id`). Devuelve JSON de éxito.

5. ARCHIVOS DE RUTAS (`web.php` y `api.php`):
- Muestra las rutas web necesarias (bajo el middleware 'guest').
- Muestra la ruta de la API protegida por Sanctum para la aprobación desde el dispositivo móvil.

Requisitos técnicos adicionales:
- Usa tipado estricto en los métodos de los controladores.
- Implementa validaciones limpias y manejo de respuestas HTTP correctas (404 si no existe, 410 si expiró, etc.).

El Concepto de QR

Un código QR no es más que la representación visual de una cadena de texto plano (una URL, un identificador o un token criptográfico). El propósito del QR en un flujo de autenticación es servir como un canal de comunicación analógico-visual para pasar un token temporal desde la pantalla del navegador hacia la cámara del teléfono.

Paquete de QR en Node

Hay muchas formas de generar un QR, en este caso, usamos un paquete de Node:

$ npm i qrcode

Modelo

Para implementar este flujo, se requiere un esquema de base de datos que actúe como puente. Se puede reutilizar una estructura existente de tokens temporales de un solo uso (como los utilizados para el inicio de sesión sin contraseña por correo electrónico), extendiendo sus propiedades mediante un campo de control de estado.

app\Models\LoginToken.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LoginToken extends Model
{
    protected $fillable = ['user_id', 'token', 'expires_at', 'status'];

    protected function casts(): array
    {
        return [
            'expires_at' => 'datetime',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function isValid(): bool
    {
        return $this->expires_at->isFuture();
    }

    public function scopePending(Builder $query): void
    {
        $query->where('status', 'pending');
    }

    public static function generateForUser(User $user): self
    {
        static::where('created_at', '<=', now()->subMinutes(5))->delete();

        $token = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        return static::create([
            'user_id' => $user->id,
            'token' => $token,
            'expires_at' => now()->addMinutes(5),
        ]);
    }

    public static function generateForQr(): self
    {
        static::where('created_at', '<=', now()->subMinutes(5))->delete();

        $token = substr(bin2hex(random_bytes(3)), 0, 6);

        return static::create([
            'user_id' => null,
            'token' => $token,
            'status' => 'pending',
            'expires_at' => now()->addMinutes(5),
        ]);
    }
}

Al estructurar la tabla, el campo user_id debe definirse como nullable (nulo por defecto). Cuando el navegador web solicita renderizar el código QR, el sistema desconoce qué usuario está frente a la pantalla; por lo tanto, el registro se crea únicamente con un token aleatorio y un estado inicial pending. Será la aplicación móvil la encargada de asociar el user_id real al escanear el código.

El Flujo de Sincronización: Consulta Activa (Long Polling)

El reto principal de este sistema es notificar al navegador web que el teléfono ya escaneó y aprobó el acceso. A diferencia de las conexiones persistentes bidireccionales como WebSockets, se puede implementar una solución de alta compatibilidad basada en Polling (consultas periódicas) utilizando JavaScript en el cliente web.

Flujo de Trabajo:

  1. Petición Web Inicial: El usuario entra a la web. El servidor genera un token único con una expiración estricta (por ejemplo, 5 minutos), almacena el registro en la base de datos con estado pending y pasa el token a la vista.
  2. Renderizado del Canvas: El frontend recibe el token y, utilizando librerías de cliente sobre un elemento <canvas>, dibuja el código QR en pantalla.
  3. Inicio del Polling: Se activa un temporizador repetitivo (setInterval) en JavaScript que realiza una petición asíncrona hacia la API cada 5 segundos. Esta consulta pregunta de manera estricta por el estado del token actual.
  4. Escaneo y Mutación (Móvil): El usuario escanea el QR desde la app móvil. Esta envía una petición firmada con su propio token de autenticación (JWT o Sanctum Bearer Token) hacia un endpoint protegido del backend, cambiando el estado del token web a approved y vinculando su ID de usuario al registro.
  5. Resolución y Login: En la siguiente consulta que realiza el navegador web (máximo 5 segundos después), el servidor detecta que el estado cambió a approved. En ese instante, el backend destruye el token temporal para evitar su reutilización, inicia la sesión web del usuario mediante los métodos nativos del framework (Auth::login()) y devuelve una respuesta de éxito que redirige al usuario a su panel de control.

resources\views\auth\login-qr.blade.php

<x-layouts::auth :title="__('Iniciar sesión con QR')">
    <x-auth-header :title="__('Escanea el código QR')" :description="__('Abre la app móvil y escanea este código para iniciar sesión.')" />

    <div class="mt-6 flex flex-col items-center space-y-6"
        x-data="qrAuth('{{ $loginToken->token }}', '{{ route('login.qr.check') }}')">

        <div x-show="status === 'pending'" class="relative">
            <canvas id="qr-canvas" class="rounded-xl"></canvas>
        </div>

        <div x-show="status === 'expired'" class="text-center space-y-4">
            <div class="text-gray-400">
                <svg class="mx-auto h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
                </svg>
            </div>
            <p class="text-gray-400 text-sm">{{ __('El código QR ha expirado.') }}</p>
            <flux:button variant="primary" x-on:click="window.location.reload()">
                {{ __('Generar nuevo QR') }}
            </flux:button>
        </div>

        <div x-show="status === 'approved'" class="text-center space-y-4">
            <div class="text-green-400">
                <svg class="mx-auto h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                </svg>
            </div>
            <p class="text-green-400 text-sm">{{ __('Inicio de sesión exitoso. Redirigiendo...') }}</p>
        </div>

        <p class="text-gray-500 text-xs text-center" x-text="statusText"></p>

        <div class="mt-4 text-center">
            <flux:link href="/login" wire:navigate>
                {{ __('Volver al inicio de sesión') }}
            </flux:link>
        </div>
    </div>

    <script>
        window.qrAuth = function(token, checkUrl) {
            return {
                token: token,
                checkUrl: checkUrl,
                status: 'pending',
                statusText: '{{ __('Generando código QR...') }}',
                pollCount: 0,
                maxPolls: 12,
                pollInterval: null,

                init() {
                    this.$nextTick(() => {
                        this.renderQr();
                    });
                },

                async renderQr() {
                    try {
                        const canvas = document.getElementById('qr-canvas');
                        if (!canvas) {
                            this.status = 'expired';
                            this.statusText = '{{ __('Error al generar el código QR') }}';
                            return;
                        }

                        await window.QRCode.toCanvas(canvas, this.token, {
                            width: 220,
                            margin: 2,
                            color: {
                                dark: '#a855f7',
                                light: '#ffffff',
                            },
                        });
                        this.statusText = '{{ __('Escanea el código con la app móvil') }}';
                        this.startPolling();
                    } catch (error) {
                        this.status = 'expired';
                        this.statusText = '{{ __('Error al generar el código QR') }}';
                    }
                },

                startPolling() {
                    this.pollInterval = setInterval(() => {
                        this.pollCount++;

                        if (this.pollCount >= this.maxPolls) {
                            this.stopPolling();
                            this.status = 'expired';
                            this.statusText = '{{ __('El código QR ha expirado') }}';
                            return;
                        }

                        fetch(`${this.checkUrl}?token=${this.token}`, {
                            headers: { 'Accept': 'application/json' },
                        })
                        .then(response => response.json())
                        .then(data => {
                            if (data.status === 'approved') {
                                this.stopPolling();
                                this.status = 'approved';
                                this.statusText = '{{ __('Inicio de sesión exitoso') }}';
                                window.location.href = data.redirect;
                            } else if (data.status === 'expired') {
                                this.stopPolling();
                                this.status = 'expired';
                                this.statusText = '{{ __('El código QR ha expirado') }}';
                            }
                        })
                        .catch(() => {
                            this.statusText = '{{ __('Error de conexión...') }}';
                        });
                    }, 5000);
                },

                stopPolling() {
                    if (this.pollInterval) {
                        clearInterval(this.pollInterval);
                        this.pollInterval = null;
                    }
                },

                destroy() {
                    this.stopPolling();
                },
            };
        };
    </script>
</x-layouts::auth>

Implementación de Endpoints en el Backend (Laravel)

El sistema requiere dos controladores lógicos: 

1. Uno público que responde a las consultas del navegador web

Este método (checkStatus) es invocado de forma recurrente por el script de polling. No requiere autenticación previa porque el cliente web actúa como un invitado intentando acceder.

routes\web.php

Route::middleware('guest')->group(function () {
    Route::get('login/qr', [QrAuthController::class, 'showQrForm'])->name('login.qr');
    Route::get('login/qr/check', [QrAuthController::class, 'checkStatus'])->name('login.qr.check');
});

app\Http\Controllers\Social\QrAuthController.php

<?php

namespace App\Http\Controllers\Social;

use App\Http\Controllers\Controller;
use App\Models\LoginToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

class QrAuthController extends Controller
{
    public function showQrForm(): View
    {
        $loginToken = LoginToken::generateForQr();

        return view('auth.login-qr', ['loginToken' => $loginToken]);
    }

    public function checkStatus(Request $request): JsonResponse
    {
        $request->validate([
            'token' => ['required', 'string', 'size:6'],
        ]);

        $loginToken = LoginToken::where('token', $request->token)->first();

        if (! $loginToken || $loginToken->expires_at->isPast()) {
            if ($loginToken) {
                $loginToken->delete();
            }

            return response()->json([
                'status' => 'expired',
            ]);
        }

        if ($loginToken->status === 'approved' && $loginToken->user_id) {
            $user = $loginToken->user;
            // limpia la sesion de invitado
            session()->regenerate();
            Auth::login($user);
            $loginToken->delete();

            return response()->json([
                'status' => 'approved',
                'redirect' => route('dashboard'),
            ]);
        }

        return response()->json([
            'status' => 'pending',
        ]);
    }
}

2. Uno protegido por middleware que atiende la confirmación del dispositivo móvil

Este endpoint debe estar protegido por el middleware de autenticación de la API (auth:sanctum). Esto garantiza que el usuario que envía la petición desde el teléfono está plenamente identificado.

routes\api.php

Route::post('login/qr/approve', [QrVerifyController::class, 'approveQr'])
    ->middleware('auth:sanctum');

app\Http\Controllers\Api\QrVerifyController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\LoginToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class QrVerifyController extends Controller
{
    public function approveQr(Request $request): JsonResponse
    {
        $request->validate([
            'token' => ['required', 'string', 'size:6'],
        ]);

        $loginToken = LoginToken::where('token', $request->token)
            ->whereNull('user_id')
            ->pending()
            ->first();

        if (! $loginToken || $loginToken->expires_at->isPast()) {
            if ($loginToken) {
                $loginToken->delete();
            }

            return response()->json([
                'message' => 'El token ha expirado o no es válido.',
            ], 422);
        }

        $loginToken->update([
            'user_id' => $request->user()->id,
            'status' => 'approved',
        ]);

        return response()->json([
            'message' => 'Inicio de sesión aprobado correctamente.',
        ]);
    }
}

Integración del Lector de Códigos QR en el Cliente Móvil (Flutter)

El cliente móvil utiliza la cámara nativa del dispositivo a través de paquetes especializados como mobile_scanner. Al detectar los datos embebidos en el QR, debe procesar la cadena, extraer el token e invocar inmediatamente el endpoint de aprobación de la API.

Es crítico para la gestión de memoria del sistema operativo liberar los recursos de hardware (la cámara) cuando el usuario abandone la pantalla de escaneo. De lo contrario, la aplicación móvil puede experimentar fugas de memoria o bloqueos visuales ("pantallas en negro").

import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:easy_localization/easy_localization.dart';

import 'package:desarrollolibre/generated/locale_keys.g.dart';
import 'package:desarrollolibre/utils/api/academy_helper.dart';
import 'package:desarrollolibre/utils/helpers.dart';

class QrLoginPage extends StatefulWidget {
  static const ROUTE = "/academy/qr-login";

  final String userToken;

  const QrLoginPage({super.key, required this.userToken});

  @override
  State<QrLoginPage> createState() => _QrLoginPageState();
}

class _QrLoginPageState extends State<QrLoginPage> {
  bool _loading = false;
  // Instanciamos el controlador para poder pausar/apagar la cámara
  final MobileScannerController _scannerController = MobileScannerController(
    detectionSpeed:
        DetectionSpeed.noDuplicates, // Evita capturas repetidas raras
  );

  @override
  void dispose() {
    // Nos aseguramos de liberar la cámara cuando el widget muera totalmente
    _scannerController.dispose();
    super.dispose();
  }

  Future<void> _onDetect(BarcodeCapture capture) async {
    if (_loading) return;

    final barcode = capture.barcodes.firstOrNull;
    if (barcode == null || barcode.rawValue == null) return;

    final token = barcode.rawValue!.trim();

    if (token.length != 6) return;

    setState(() => _loading = true);

    // Puntos clave de estabilidad: Pausamos el escáner para que no siga
    // analizando imágenes mientras procesamos la petición HTTP
    await _scannerController.stop();

    final success = await AcademyHelper.qrLoginApprovePost(
      widget.userToken,
      token,
    );

    if (!mounted) return;

    setState(() => _loading = false);

    if (success) {
      showToastMessage(context, LocaleKeys.qrLoginApproved.tr());
      // Volvemos de forma segura, la cámara ya está apagada
      Navigator.of(context).pop(true);
    } else {
      showToastMessage(context, LocaleKeys.qrTokenExpired.tr());
      // Si falló (ej: token web expirado), volvemos a encender la cámara para reintentar
      await _scannerController.start();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(LocaleKeys.scanQr.tr())),
      body: Stack(
        children: [
          MobileScanner(
            controller: _scannerController, // Asignamos el controlador
            onDetect: _onDetect,
          ),
          if (_loading)
            Container(
              color: Colors
                  .black54, // Oscurece un poco la pantalla durante el loading
              child: const Center(child: CircularProgressIndicator()),
            ),
        ],
      ),
    );
  }
}

Sincronización de Proyectos y Transferencia de Contexto entre IAs

Al desarrollar sistemas que involucran arquitecturas desacopladas (como un backend en Laravel y un cliente móvil independiente en Flutter), uno de los desafíos más grandes es mantener la coherencia del diseño entre ambos proyectos cuando se trabaja con Inteligencia Artificial.

Si abres un chat nuevo para el desarrollo móvil sin proporcionarle el contexto del backend que construiste previamente, la IA carecerá de visibilidad sobre los nombres de los endpoints, las estructuras de las peticiones, los parámetros requeridos o los mecanismos de autenticación. Como consecuencia, generará código inconsistente que romperá la integración.

Existen dos estrategias principales para transferir el contexto técnico de un proyecto a otro de forma eficiente y evitar tener que reescribir las especificaciones desde cero:

1. Estrategia de Directorio Unificado (Monorrepo Temporal)

La solución más directa para que la IA asimile el contexto completo de ambos ecosistemas consiste en agrupar temporalmente ambos proyectos dentro de una misma raíz o espacio de trabajo (por ejemplo, abriendo la carpeta contenedora general en el editor de código).

Al clonar o mover la carpeta del proyecto de Flutter directamente al lado del directorio de Laravel, compartes un mismo entorno de ejecución. Esto permite que las herramientas de asistencia al programador o los terminales integrados de la IA lean e indexen de manera automática los modelos de Eloquent, las rutas de la API y los controladores de un lado, para luego autocompletar o generar las vistas, llamadas HTTP y controladores de estado en Flutter del otro, manteniendo una coherencia absoluta en las firmas de los métodos.

Estrategia de Documentación Técnica Dinámica (Context Markdown)

Si prefieres mantener ambos repositorios estrictamente separados por razones de arquitectura o rendimiento de tu equipo, el método ideal es obligar a la primera IA (la que desarrolló el backend) a compilar un resumen ejecutivo estructurado en formato Markdown (.md).

Prompt: 

Genera un resumen técnico estructurado en un único bloque Markdown (.md) que contenga exclusivamente el contexto necesario para que otra IA implemente el cliente móvil en Flutter. Incluye:
1. Endpoints de la API generados con sus métodos HTTP.
2. Formato exacto de los objetos JSON que espera el servidor (Request) y los que responde (Response).
3. Reglas de negocio críticas (parámetros obligatorios, expiración de tokens, estados).
Evita introducciones y ve directo al formato Markdown.

Este archivo técnico actúa como un contrato de API o manifiesto de sincronización, el cual puedes arrastrar directamente al inicio de la conversación del nuevo proyecto móvil y este fue el resultado:

# Integración de Autenticación por Código al Correo en Flutter

## Flujo

1. El usuario ingresa su email → se envía código de 6 dígitos a su correo.
2. El usuario ingresa el código → se verifica y se obtiene un token Sanctum.

---

## 1. Enviar código al correo

```
POST https://laradesarrollolibre13.test/api/v1/login/token/send
```

### Headers

| Header | Value |
|--------|-------|
| `Accept` | `application/json` |
| `Content-Type` | `application/json` |

### Cuerpo

```json
{
 "email": "usuario@example.com"
}
```

### Respuesta 200

```json
{
 "message": "Código enviado a tu correo electrónico.",
 "token_id": 42
}
```

El `token_id` debe almacenarse localmente para el segundo paso.

### Respuesta 422 — Email no existe

```json
{
 "message": "...",
 "errors": {
   "email": ["The selected email is invalid."]
 }
}
```

### Rate limit

10 requests por minuto por email+IP.

---

## 2. Verificar código

```
POST https://laradesarrollolibre13.test/api/v1/login/token/verify
```

### Headers

| Header | Value |
|--------|-------|
| `Accept` | `application/json` |
| `Content-Type` | `application/json` |

### Cuerpo

```json
{
 "token_id": 42,
 "token": "583291"
}
```

### Respuesta 200 — Login exitoso

```json
{
 "message": "Inicio de sesión exitoso.",
 "user": {
   "id": 1,
   "name": "Usuario",
   "email": "usuario@example.com",
   ...
 },
 "token": "1|sanctum_token_plain_text_aqui"
}
```

El `token` es un token Sanctum en texto plano. Debe almacenarse de forma segura y enviarse como `Authorization: Bearer {token}` en adelante.

### Respuesta 422 — Código incorrecto

```json
{
 "message": "El código ingresado no es correcto."
}
```

### Respuesta 422 — Código expirado

```json
{
 "message": "El código ha expirado. Solicita uno nuevo."
}
```

---

## Dependencia

```yaml
dependencies:
 http: ^1.2.0
```

## Implementación

```dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

class EmailTokenAuth {
 final String apiBaseUrl;
 final http.Client _client = http.Client();

 EmailTokenAuth({required this.apiBaseUrl});

 Future<({int tokenId, String message})> sendToken(String email) async {
   final response = await _client.post(
     Uri.parse('$apiBaseUrl/api/v1/login/token/send'),
     headers: {
       'Accept': 'application/json',
       'Content-Type': 'application/json',
     },
     body: jsonEncode({'email': email}),
   );

   final body = jsonDecode(response.body);

   if (response.statusCode != 200) {
     throw Exception(body['message'] ?? 'Error al enviar código');
   }

   return (tokenId: body['token_id'] as int, message: body['message'] as String);
 }

 Future<({String message, Map user, String token})> verifyToken({
   required int tokenId,
   required String code,
 }) async {
   final response = await _client.post(
     Uri.parse('$apiBaseUrl/api/v1/login/token/verify'),
     headers: {
       'Accept': 'application/json',
       'Content-Type': 'application/json',
     },
     body: jsonEncode({'token_id': tokenId, 'token': code}),
   );

   final body = jsonDecode(response.body);

   if (response.statusCode != 200) {
     throw Exception(body['message'] ?? 'Error al verificar código');
   }

   return (
     message: body['message'] as String,
     user: body['user'] as Map,
     token: body['token'] as String,
   );
 }

 void dispose() {
   _client.close();
 }
}
```

## Ejemplo de uso con UI

```dart
class EmailLoginScreen extends StatefulWidget {
 final String apiBaseUrl;

 const EmailLoginScreen({super.key, required this.apiBaseUrl});

 @override
 State<EmailLoginScreen> createState() => _EmailLoginScreenState();
}

class _EmailLoginScreenState extends State<EmailLoginScreen> {
 final _auth = EmailTokenAuth(apiBaseUrl: 'https://laradesarrollolibre13.test');
 final _emailCtrl = TextEditingController();
 final _codeCtrl = TextEditingController();
 bool _loading = false;
 int? _tokenId;
 String? _message;

 Future<void> _sendToken() async {
   setState(() => _loading = true);
   try {
     final result = await _auth.sendToken(_emailCtrl.text.trim());
     setState(() {
       _tokenId = result.tokenId;
       _message = result.message;
     });
   } catch (e) {
     if (mounted) {
       ScaffoldMessenger.of(context).showSnackBar(
         SnackBar(content: Text('$e')),
       );
     }
   } finally {
     if (mounted) setState(() => _loading = false);
   }
 }

 Future<void> _verifyToken() async {
   if (_tokenId == null) return;

   setState(() => _loading = true);
   try {
     final result = await _auth.verifyToken(
       tokenId: _tokenId!,
       code: _codeCtrl.text.trim(),
     );

     if (mounted) {
       // Guardar result.token como Sanctum token para próximas requests
       // Navegar a home
     }
   } catch (e) {
     if (mounted) {
       ScaffoldMessenger.of(context).showSnackBar(
         SnackBar(content: Text('$e')),
       );
     }
   } finally {
     if (mounted) setState(() => _loading = false);
   }
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Iniciar sesión')),
     body: Padding(
       padding: const EdgeInsets.all(24),
       child: Column(
         children: [
           TextField(
             controller: _emailCtrl,
             decoration: const InputDecoration(labelText: 'Email'),
             keyboardType: TextInputType.emailAddress,
           ),
           const SizedBox(height: 16),
           ElevatedButton(
             onPressed: _loading ? null : _sendToken,
             child: const Text('Enviar código'),
           ),
           if (_tokenId != null) ...[
             const SizedBox(height: 24),
             TextField(
               controller: _codeCtrl,
               decoration: const InputDecoration(
                 labelText: 'Código de 6 dígitos',
                 hintText: '123456',
               ),
               keyboardType: TextInputType.number,
               maxLength: 6,
             ),
             const SizedBox(height: 16),
             ElevatedButton(
               onPressed: _loading ? null : _verifyToken,
               child: const Text('Verificar código'),
             ),
           ],
           if (_loading) const Padding(
             padding: EdgeInsets.all(16),
             child: CircularProgressIndicator(),
           ),
         ],
       ),
     ),
   );
 }

 @override
 void dispose() {
   _auth.dispose();
   _emailCtrl.dispose();
   _codeCtrl.dispose();
   super.dispose();
 }
}
```

## Notas

- El código expira a los 5 minutos de enviarse.
- `token_id` es el ID del registro en la tabla `login_tokens`, necesario para verificar.
- El token Sanctum retornado debe persistirse (SharedPreferences, secure storage, etc.).
- Hay rate limit de 10 requests/minuto para ambos endpoints.
- Ambos endpoints son públicos (no requieren autenticación previa).

Autenticación mediante Códigos Temporales (OTP) por Correo Electrónico en Laravel Fortify

Video thumbnail

La autenticación basada en códigos temporales de un solo uso o OTP (One-Time Password) representa una alternativa moderna e inalámbrica al flujo clásico de credenciales. Este mecanismo permite verificar la identidad del usuario enviando un token numérico de seis dígitos directamente a su buzón de correo electrónico, eliminando la necesidad de gestionar o recordar contraseñas en el dispositivo cliente.

A nivel de experiencia de usuario, este flujo se compone comúnmente de dos interfaces consecutivas: una primera pantalla para la captura y validación del correo electrónico, y una segunda ventana dedicada exclusivamente a la introducción y verificación del token recibido.

1. El Flujo de Generación y Envío del Token

Cuando el usuario envía el formulario con su dirección de correo electrónico, el backend procesa la solicitud a través de un método de despacho que coordina la persistencia, la seguridad y la mensajería:

[ Cliente Web ] --( 1. Envía Email )--> [ Backend: sendToken() ]
                                               |
                                        ( 2. Busca Usuario )
                                        ( 3. Registra OTP )
                                               |
                                        ( 4. Guarda en Sesión )
                                               |
                                               v
[ Bandeja de Entrada ] <--( 5. Despacha Mail )-- [ Servicio de Correo ]

Enviar Token OTP

Este es el controlador para:

  1. showEmailForm(): Pintar el formulario para solicitar el email
  2. showCodeForm(): Pintar el formulario para preguntar por el token
  3. sendToken(): Generar Token OTP
  4. verifyToken(): Validar Token OTP

app\Http\Controllers\Social\LoginTokenController.php

<?php

namespace App\Http\Controllers\Social;

use App\Http\Controllers\Controller;
use App\Mail\LoginTokenMail;
use App\Models\LoginToken;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;

class LoginTokenController extends Controller
{
    public function showEmailForm(): View
    {
        return view('auth.login-token-email');
    }

    public function sendToken(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email', 'exists:users,email'],
        ]);

        $user = User::where('email', $request->email)->first();

        $loginToken = LoginToken::generateForUser($user);

        Mail::to($user)->send(new LoginTokenMail($user, $loginToken->token));

        session(['login_token_id' => $loginToken->id]);

        return redirect()->route('login.token.code');
    }

    public function showCodeForm(): View|RedirectResponse
    {
        if (! session('login_token_id')) {
            return redirect()->route('login.token');
        }

        return view('auth.login-token-code');
    }

    public function verifyToken(Request $request): RedirectResponse
    {
        $request->validate([
            'token' => ['required', 'string', 'size:6'],
        ]);

        $tokenId = session('login_token_id');

        if (! $tokenId) {
            return redirect()->route('login.token')
                ->withErrors(['email' => 'La sesión ha expirado. Solicita un nuevo código.']);
        }

        $loginToken = LoginToken::find($tokenId);

        if (! $loginToken || ! $loginToken->isValid()) {
            session()->forget('login_token_id');

            return redirect()->route('login.token')
                ->withErrors(['token' => 'El código ha expirado. Solicita uno nuevo.']);
        }

        if ($loginToken->token !== $request->token) {
            return redirect()->back()
                ->withErrors(['token' => 'El código ingresado no es correcto.']);
        }

        $user = $loginToken->user;

        Auth::login($user);
        $loginToken->delete();
        session()->forget('login_token_id');
        session()->regenerate();

        return redirect()->intended('/dashboard');
    }
}

Guardar el identificador del token generado (auth_token_id) dentro de la sesión web del cliente en lugar de apoyarse únicamente en el ID del usuario es un requisito de seguridad arquitectónico. Si un usuario intenta iniciar sesión de manera simultánea desde dos navegadores distintos, cada entorno aislará su propio flujo en una sesión HTTP independiente. De este modo, el sistema sabe con precisión qué código evaluar en cada pantalla sin mezclar las peticiones pendientes en la base de datos.

Rutas:

routes\web.php

<?php

use App\Http\Controllers\Social\LoginTokenController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::get('login/token', [LoginTokenController::class, 'showEmailForm'])->name('login.token');
    Route::post('login/token', [LoginTokenController::class, 'sendToken'])->name('login.token.send')
        ->middleware('throttle:login-token');
    Route::get('login/token/code', [LoginTokenController::class, 'showCodeForm'])->name('login.token.code');
    Route::post('login/token/verify', [LoginTokenController::class, 'verifyToken'])->name('login.token.verify')
        ->middleware('throttle:login-token');
});

Modelo

A diferencia del flujo de autenticación por QR, donde el campo user_id se inicializa como nulo debido a que se desconoce la identidad del cliente que está frente a la pantalla, en la autenticación OTP el user_id se vincula de manera explícita desde el primer instante. Sin embargo, ambos flujos comparten exactamente la misma estructura de datos y el mismo periodo de expiración estricto de cinco minutos (generateForQr()).

app\Models\LoginToken.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LoginToken extends Model
{
    protected $fillable = ['user_id', 'token', 'expires_at', 'status'];

    protected function casts(): array
    {
        return [
            'expires_at' => 'datetime',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function isValid(): bool
    {
        return $this->expires_at->isFuture();
    }

    public function scopePending(Builder $query): void
    {
        $query->where('status', 'pending');
    }

    public static function generateForUser(User $user): self
    {
        static::where('created_at', '<=', now()->subMinutes(5))->delete();

        $token = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        return static::create([
            'user_id' => $user->id,
            'token' => $token,
            'expires_at' => now()->addMinutes(5),
        ]);
    }
}

Vistas

La vista de preguntar por el email:

resources\views\auth\login-token-email.blade.php

<x-layouts::auth :title="__('Iniciar sesión con código')">
    <x-auth-header :title="__('Iniciar sesión con código')" :description="__('Ingresa tu email para recibir un código de verificación de 6 dígitos.')" />

    <form method="POST" action="{{ route('login.token.send') }}" class="mt-6 space-y-6">
        @csrf

        <flux:input
            type="email"
            name="email"
            :label="__('Email')"
            :placeholder="__('tu@email.com')"
            required
            autofocus
        />

        @if ($errors->has('email'))
            <flux:error>{{ $errors->first('email') }}</flux:error>
        @endif

        <flux:button type="submit" variant="primary" class="w-full">
            {{ __('Enviar código') }}
        </flux:button>
    </form>

    <div class="mt-6 text-center">
        <flux:link href="/login" wire:navigate>
            {{ __('Volver al inicio de sesión') }}
        </flux:link>
    </div>
</x-layouts::auth>

Por el código OTP:

resources\views\emails\login-token.blade.php

@extends('emails.layout')

@section('content')

<h2 style="margin: 0 0 24px 0; font-size: 24px; font-weight: 700; color: #111827;">
    ¡Hola {{ $user->name }}!
</h2>

<p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.6; color: #4b5563;">
    Has solicitado iniciar sesión en <strong style="color: #111827;">{{ config('app.name') }}</strong>.
    Aquí tienes tu código de verificación:
</p>

<div class="card card-purple" style="background-color: #7c3aed; color: #ffffff; border: none; border-radius: 8px; padding: 24px; margin: 24px 0; text-align: center;">
    <p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600; color: #e9d5ff; text-transform: uppercase; letter-spacing: 1px;">
        Tu código de inicio de sesión
    </p>
    <p style="margin: 0; font-size: 36px; font-weight: 700; color: #ffffff; letter-spacing: 8px;">
        {{ $token }}
    </p>
</div>

<div class="panel" style="border-left: 4px solid #7c3aed; background-color: #f3f4f6; padding: 16px 20px; margin: 20px 0; border-radius: 4px;">
    <p style="margin: 0; font-size: 14px; color: #374151; line-height: 1.6;">
        <strong>Importante:</strong> Este código expira en <strong>5 minutos</strong>.
        Si no solicitaste este inicio de sesión, ignora este mensaje.
    </p>
</div>

<hr class="divider" style="border: 0; border-top: 1px solid #e5e7eb; margin: 32px 0;">

<p style="margin: 0 0 8px 0; font-size: 16px; color: #4b5563; line-height: 1.6;">
    Saludos,
</p>
<p style="margin: 0; font-size: 16px; font-weight: 600; color: #111827;">
    {{ config('app.name') }}
</p>

@endsection

Y la clase:

app\Mail\LoginTokenMail.php

<?php

namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class LoginTokenMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public User $user,
        public string $token,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Tu código de inicio de sesión',
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.login-token',
        );
    }

    public function attachments(): array
    {
        return [];
    }
}

Adaptabilidad e Integración en Clientes Móviles (API)

Este flujo de autenticación es agnóstico respecto a la tecnología cliente y puede integrarse perfectamente en aplicaciones móviles (como un desarrollo nativo en Flutter) exponiendo los mismos métodos a través de una API RESTful.

La única variación arquitectónica radica en el método de autorización final: mientras que la plataforma web genera una cookie de sesión tras ejecutar Auth::login(), el endpoint de la API dedicado al entorno móvil debe interceptar la validación del código y retornar un token de acceso personal gestionado por Laravel Sanctum.

app\Http\Controllers\Api\LoginTokenController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Mail\LoginTokenMail;
use App\Models\LoginToken;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

class LoginTokenController extends Controller
{
    public function sendToken(Request $request): JsonResponse
    {
        $request->validate([
            'email' => ['required', 'email', 'exists:users,email'],
        ]);

        $user = User::where('email', $request->email)->first();

        $loginToken = LoginToken::generateForUser($user);

        Mail::to($user)->send(new LoginTokenMail($user, $loginToken->token));

        return response()->json([
            'message' => 'Código enviado a tu correo electrónico.',
            'token_id' => $loginToken->id,
        ]);
    }

    public function verifyToken(Request $request): JsonResponse
    {
        $request->validate([
            'token_id' => ['required', 'integer', 'exists:login_tokens,id'],
            'token' => ['required', 'string', 'size:6'],
        ]);

        $loginToken = LoginToken::findOrFail($request->token_id);

        if (! $loginToken->isValid()) {
            $loginToken->delete();

            return response()->json([
                'message' => 'El código ha expirado. Solicita uno nuevo.',
            ], 422);
        }

        if ($loginToken->token !== $request->token) {
            return response()->json([
                'message' => 'El código ingresado no es correcto.',
            ], 422);
        }

        $user = $loginToken->user;
        $loginToken->delete();

        return response()->json([
            'message' => 'Inicio de sesión exitoso.',
            'user' => $user,
            'token' => getUserTokenAuth(user: $user),
        ]);
    }
}

Ruta:

routes\api.php

use App\Http\Controllers\Api\LoginTokenController;
use Illuminate\Support\Facades\Route;

Route::post('login/token/send', [LoginTokenController::class, 'sendToken'])
    ->middleware('throttle:login-token');
Route::post('login/token/verify', [LoginTokenController::class, 'verifyToken'])
    ->middleware('throttle:login-token');

Al igual que en el sistema de QR, la emisión de este Bearer Token debe centralizarse en una única función de ayuda global en el backend, garantizando que cualquier regla de negocio o restricción de dispositivos se evalúe en un solo punto antes de conceder acceso a la plataforma.

function getUserTokenAuth(string $token = 'myapptoken', ?User $user = null)
{
    if ($user == null) {
        $user = auth()->user() ?? auth('sanctum')->user();
    }
    
    if ($user != null) {
        // 1. Capturamos el User-Agent del Request
        $userAgent = request()->userAgent() ?? 'Unknown Device';

        // 2. Formateamos el User-Agent para que sea un nombre de dispositivo corto y legible
        if (str_contains($userAgent, 'Dart')) {
            // Peticiones de Flutter (El User-Agent por defecto suele ser "Dart/X.X (dart:io)")
            $deviceName = 'App Móvil (Flutter)';
        } elseif (str_contains($userAgent, 'Postman')) {
            $deviceName = 'Postman API Client';
        } elseif (str_contains($userAgent, 'Chrome')) {
            $deviceName = 'Navegador Chrome';
        } elseif (str_contains($userAgent, 'Safari') && ! str_contains($userAgent, 'Chrome')) {
            $deviceName = 'Navegador Safari';
        } elseif (str_contains($userAgent, 'Firefox')) {
            $deviceName = 'Navegador Firefox';
        } else {
            // Si es un agente raro, tomamos los primeros 40 caracteres legibles
            $deviceName = Str::limit($userAgent, 40, '...');
        }

        // 4. Creamos el nuevo token limpio asignándole el nombre formateado
        return $user->createToken($deviceName)->plainTextToken;

    }

    return '';
}

Preguntas frecuentes sobre Laravel Breeze

  • ¿Laravel Breeze funciona en Laravel 13?
    • Sí, funciona perfectamente, aunque se instala manualmente.
  • ¿Puedo usar Breeze solo como base y luego eliminar cosas?
    • Sí, de hecho es una práctica habitual.
  • ¿Cuándo debería pasar a Jetstream?
    • Cuando necesitas equipos, 2FA u otras funciones avanzadas.
  • ¿Breeze sirve para proyectos en producción?
    • Sí, siempre que adaptes la seguridad y configuración al entorno real.
  • Qué archivos y rutas crea Laravel Breeze
    • Una vez instalado, Breeze modifica tu proyecto.
    • Rutas de autenticación y middleware auth
      • Breeze crea rutas para:
      • /login
      • /register
      • /forgot-password
      • /dashboard

Conclusión

Laravel Breeze no es solo un “paquete de autenticación”. Es una forma limpia y didáctica de empezar proyectos en Laravel, entender su estructura y construir sobre una base sólida.

Si estás dando tus primeros pasos, empieza simple, entiende lo que usas y crece desde ahí. Breeze encaja perfectamente en esa filosofía.

Siguiente paso, aprende a crear autenticación social en Google/Gmail o GitHub con Laravel Socialite.

Laravel Fortify es un paquete para la autenticación, registro, recuperación de contraseña, verificación de correo electrónico y más, en pocas palabras, permite realizar las mismas funcionalidades que Laravel Breeze que empleamos antes, pero sin parte gráfica.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.

Andrés Cruz

EN In english