15 de abril de 2024

Autenticación sin contraseña en Laravel: magic links con URLs firmadas y Livewire

Foto de Marco Orta Marco Orta | 6 mins de lectura
Compartir
Pantalla mostrando un enlace mágico en un correo electrónico para iniciar sesión sin contraseña

⚙️ Actualizado para Laravel 13. El código de este tutorial sigue funcionando sin cambios. Si quieres ir un paso más allá, Laravel 13 introduce soporte nativo para Passkeys (WebAuthn), una alternativa moderna a los magic links que mencionamos al final del artículo.

A veces no queremos que los usuarios tengan contraseñas. En ocasiones, queremos enviar un enlace mágico al correo electrónico del usuario y que hagan clic para obtener acceso.

En este tutorial, te guiaré a través de un proceso que puedes usar para implementarlo tú mismo. El enfoque principal de este flujo de trabajo es crear una URL firmada que nos permitirá enviar una URL específica al correo electrónico de los usuarios, y solo esa persona debería poder acceder a esta URL.

Primero queremos eliminar el campo de contraseña de nuestra migración, modelo y fábrica de modelos. Como no será necesario, queremos asegurarnos de eliminarlo, ya que no es una columna que admita nulos por defecto. Este es un proceso relativamente sencillo de lograr, así que no mostraré ejemplos de código para esta parte. Mientras estamos en ello, también podemos eliminar la tabla de restablecimientos de contraseña, ya que no tendremos una contraseña para restablecer.

La siguiente cosa que deberíamos mirar es el enrutamiento. Podemos crear nuestra ruta de inicio de sesión como una ruta de vista simple, ya que usaremos Livewire para este ejemplo. Echemos un vistazo a cómo registrar esta ruta:

Route::middleware(['guest'])->group(static function (): void {
    Route::view('login', 'app.auth.login')->name('login');
});

Queremos envolver esto en el middleware de invitado para forzar una redirección si el usuario ya está conectado. No revisaré la interfaz de usuario para este ejemplo, pero al final del tutorial, hay un enlace al repositorio en GitHub. Vamos a revisar el componente Livewire que usaremos para el formulario de inicio de sesión.

final class LoginForm extends Component
{
    public string $email = '';
 
    public string $status = '';
 
    public function submit(SendLoginLink $action): void
    {
        $this->validate();
 
        $action->handle(
            email: $this->email,
        );
 
        $this->status = 'Se ha enviado un correo electrónico para que puedas iniciar sesión.';
    }
 
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email',
                Rule::exists(
                    table: 'users',
                    column: 'email',
                ),
            ]
        ];
    }
 
    public function render(): View
    {
        return view('livewire.auth.login-form');
    }
}

Nuestro componente tiene dos propiedades que querremos usar. El correo electrónico se usa para capturar la entrada del formulario. Luego, el estado es para no tener que depender de la sesión de la solicitud. Tenemos un método que devuelve las reglas de validación. Este es mi enfoque preferido para las reglas de validación en un componente de Livewire. Nuestro método de envío es el método principal para este componente, y es una convención de nombres que utilizo cuando trato con componentes de formulario. Esto tiene mucho sentido para mí, pero siéntete libre de elegir un método de denominación que funcione para ti. Usamos el contenedor de Laravel para inyectar una clase de acción en este método para compartir la lógica de creación y envío de una URL firmada. Todo lo que necesitamos hacer aquí es pasar el correo electrónico ingresado a la acción y establecer una alerta de estado informando al usuario que el correo electrónico está siendo enviado.

Ahora, revisemos la acción que queremos usar.

final class SendLoginLink
{
    public function handle(string $email): void
    {
        Mail::to(
            users: $email,
        )->send(
            mailable: new LoginLink(
                url: URL::temporarySignedRoute(
                    name: 'login:store',
                    parameters: [
                        'email' => $email,
                    ],
                    expiration: 3600,
                ),
            )
        );
    }
}

Esta acción solo necesita enviar un correo electrónico. Podemos configurar esto para que se encole si queremos, pero cuando se trata de una acción que requiere un procesamiento rápido, es mejor encolarla si estamos construyendo una API. Tenemos una clase mailable llamada LoginLink a la que pasamos la URL que queremos usar. Nuestra URL se crea pasando el nombre de una ruta para la que queremos generar una ruta y pasando los parámetros que quieres usar como parte de la firma.

final class LoginLink extends Mailable
{
    use Queueable, SerializesModels;
 
    public function __construct(
        public readonly string $url,
    ) {}
 
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Tu enlace mágico esta aquí!',
        );
    }
 
    public function content(): Content
    {
        return new Content(
            markdown: 'emails.auth.login-link',
            with: [
                'url' => $this->url,
            ],
        );
    }
 
    public function attachments(): array
    {
        return [];
    }
}

Nuestra clase mailable es relativamente sencilla y no difiere mucho de un mailable estándar. Pasamos una cadena para la URL. Luego, queremos pasar esto a una vista markdown en el contenido.

<x-mail::message>
# Login Link
 
Usa el siguiente enlace para iniciar sesión en {{ config('app.name') }}.
 
<x-mail::button :url="$url">
Iniciar Sesión
</x-mail::button>
 
Gracias,<br>
{{ config('app.name') }}
</x-mail::message>

El usuario recibirá este correo electrónico y hará clic en el enlace, llevándolos a la URL firmada. Registremos esta ruta y veamos cómo se ve.

Route::middleware(['guest'])->group(static function (): void {
    Route::view('login', 'app.auth.login')->name('login');
    Route::get(
        'login/{email}',
        LoginController::class,
    )->middleware('signed')->name('login:store');
});

Queremos usar un controlador para esta ruta y asegurarnos de añadir el middleware firmado. Ahora veamos el controlador para ver cómo manejamos las URLs firmadas.

final class LoginController
{
    public function __invoke(Request $request, string $email): RedirectResponse
    {
        if (! $request->hasValidSignature()) {
            abort(Response::HTTP_UNAUTHORIZED);
        }
 
        /**
         * @var User $user
         */
        $user = User::query()->where('email', $email)->firstOrFail();
 
        Auth::login($user);
 
        return new RedirectResponse(
            url: route('dashboard:show'),
        );
    }
}

Nuestro primer paso es asegurarnos de que la URL tenga una firma válida y, si no la tiene, queremos lanzar una respuesta no autorizada. Una vez que sabemos que la firma es válida, podemos consultar al usuario pasado y autenticarlo. Finalmente, devolvemos una redirección al panel de control.

Nuestro usuario ahora ha iniciado sesión con éxito, y nuestro recorrido está completo. Sin embargo, también necesitamos mirar la ruta de registro. Añadamos esta ruta a continuación. Nuevamente, será una ruta de vista.

Route::middleware(['guest'])->group(static function (): void {
    Route::view('login', 'app.auth.login')->name('login');
    Route::get(
        'login/{email}',
        LoginController::class,
    )->middleware('signed')->name('login:store');
 
    Route::view('register', 'app.auth.register')->name('register');
});

De nuevo, utilizamos un componente Livewire para el formulario de registro, al igual que hicimos con el proceso de inicio de sesión.

final class RegisterForm extends Component
{
    public string $name = '';
 
    public string $email = '';
 
    public string $status = '';
 
    public function submit(CreateNewUser $user, SendLoginLink $action): void
    {
        $this->validate();
 
        $user = $user->handle(
            name: $this->name,
            email: $this->email,
        );
 
        if (! $user) {
            throw ValidationException::withMessages(
                messages: [
                    'email' => 'Ocurrió un error, por favor intentelo nuevamente.',
                ],
            );
        }
 
        $action->handle(
            email: $this->email,
        );
 
        $this->status = 'Te hemos enviado un email para que puedas iniciar sesión.';
    }
 
    public function rules(): array
    {
        return [
            'name' => [
                'required',
                'string',
                'min:2',
                'max:55',
            ],
            'email' => [
                'required',
                'email',
            ]
        ];
    }
 
    public function render(): View
    {
        return view('livewire.auth.register-form');
    }
}

Capturamos el nombre y la dirección de correo electrónico del usuario y tenemos una propiedad de estado en lugar de utilizar nuevamente la sesión de solicitud. Nuevamente utilizamos un método de reglas para devolver las reglas de validación para esta solicitud. Regresamos al método de envío, donde esta vez, queremos inyectar dos acciones.

CreateNewUser es la acción que utilizamos para crear y devolver un nuevo usuario basado en la información proporcionada. Si esto falla por alguna razón, lanzamos una excepción de validación en el correo electrónico. Luego usamos la acción SendLoginLink que utilizamos en el formulario de inicio de sesión para minimizar la duplicación de código.

final class CreateNewUser
{
    public function handle(string $name, string $email): Builder|Model
    {
        return User::query()->create([
            'name' => $name,
            'email' => $email,
        ]);
    }
}

Podríamos renombrar la ruta de almacenamiento de inicio de sesión, pero técnicamente es lo que estamos haciendo de nuevo. Creamos un usuario. Luego queremos iniciar sesión con el usuario.

Este es uno de los muchos enfoques que puedes tomar para implementar la autenticación sin contraseña, pero es un enfoque que funciona.

Bonus 2026: passkeys con Laravel 13

A partir de Laravel 13 (marzo 2026), el framework incluye soporte de primera clase para passkeys basadas en WebAuthn. Los passkeys son el siguiente paso evolutivo respecto al magic link: no requieren correo, son resistentes a phishing y los dispositivos modernos (iPhone, Android, macOS, Windows) los gestionan de forma nativa con Face ID / Touch ID / Windows Hello.

php artisan install:api    # si no lo tenías ya
# El AI SDK / Passkey scaffolding vienen incluidos en Laravel 13

La idea conceptual:

  1. El usuario se registra una vez con email + passkey (el navegador guarda una clave criptográfica en su dispositivo).
  2. En el siguiente login basta con que apruebe la solicitud biométricamente, sin teclear nada ni esperar emails.

Si estás construyendo un producto nuevo en 2026, vale mucho la pena considerar passkeys como flujo principal y dejar los magic links como fallback de recuperación.

Si te interesa profundizar en Laravel 13, no te pierdas mi guía de Cómo instalar Laravel 13 y Cómo crear una API REST con Laravel 13.

Compartir

Buscar

Etiquetas