En este tutorial vamos a ver cómo crear un login sin contraseña en Laravel. En lugar de pedirle una contraseña al usuario, le enviaremos un código de seis cifras a su email, que tendrá que introducir para acceder a su cuenta.

De este modo te ahorrarás tener que gestionar contraseñas: no tendrás que guardarlas, ni crear un formulario para recuperarlas, ni enviar enlaces para restablecerlas. Además, el mismo formulario servirá tanto para iniciar sesión como para registrarse o recuperar el acceso.

Introducción

Vamos a crear una sencilla aplicación con Laravel en la que el usuario podrá identificarse usando únicamente su email. La primera vez que introduzca el código correcto se creará su cuenta, y las siguientes veces simplemente iniciará sesión.

También protegeremos el formulario con Cloudflare Turnstile, usando el paquete Laracaptcha, que permite usar tanto Turnstile como reCAPTCHA con la misma API. Cada código es un email que se envía desde tu dominio a la dirección que alguien haya escrito, por lo que no queremos que nadie pueda usar el formulario para enviar correos a desconocidos.

Antes de continuar, es recomendable que tengas ciertos conocimientos previos de PHP y HTML. Para ello puedes consultar las siguientes guías:

También necesitarás tener ciertos conocimientos básicos acerca de la línea de comandos. Si no los tienes, consulta el siguiente tutorial:

Puedes consultar el código de la aplicación que vamos a crear en GitHub. Este es el aspecto que tendrá el formulario de acceso:

Formulario de login sin contraseña con Cloudflare Turnstile

Instalación de Laravel

Antes de instalar Laravel, tendrás que asegurarte de que tienes composer instalado en tu sistema, así como PHP 8.3 o superior. Si no tienes PHP instalado en tu sistema, consulta el tutorial de instalación de PHP en Windows.

Crea un proyecto con Laravel

Para crear un proyecto con Laravel, abre una ventana de línea de comandos y sigue estos pasos:

  1. Sitúate en el directorio en el que quieres crear el proyecto y ejecuta el comando create-project, reemplazando login-sin-contrasena por el nombre que quieras darle al proyecto:

    composer create-project laravel/laravel login-sin-contrasena
    
  2. Seguidamente, accede al proyecto usando el comando cd:

    cd login-sin-contrasena
    
  3. Ahora ya podrás iniciar el servidor local mediante el siguiente comando:

    php artisan serve
    
  4. Con esto ya habrás creado el proyecto.

Laravel usa SQLite por defecto, así que no tendrás que configurar ninguna base de datos. Si quieres conocer otras formas de instalar Laravel, consulta la documentación de instalación de Laravel.

Instala Laracaptcha

Para agregar el widget de Turnstile y validar sus respuestas usaremos Laracaptcha. Para instalarlo, ejecuta este comando desde el directorio del proyecto:

composer require edulazaro/laracaptcha

Si nunca has agregado un paquete a Laravel, puedes consultar el tutorial en el que explico cómo agregar un paquete a Laravel con Composer. En la documentación de Laracaptcha encontrarás todas las opciones del paquete, como el uso de reCAPTCHA o su integración con Livewire.

Configura Turnstile y el correo

A continuación edita el archivo .env y agrega estas variables:

CAPTCHA_DRIVER=turnstile
TURNSTILE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET=1x0000000000000000000000000000000AA

Estas son las claves de prueba que publica Cloudflare, que siempre dan la verificación por buena. Te servirán para trabajar en local, pero en producción tendrás que usar las tuyas, que podrás crear gratis desde el panel de Cloudflare.

En cuanto al correo, asegúrate de que la variable MAIL_MAILER tiene el valor log:

MAIL_MAILER=log

De este modo los emails no se enviarán, sino que se escribirán en el archivo storage/logs/laravel.log. Es ahí en donde veremos los códigos mientras desarrollamos la aplicación.

Configuración inicial del proyecto

Lo primero que vamos a hacer es adaptar la tabla de usuarios y definir las rutas y el controlador del login.

Modifica la tabla de usuarios

La tabla users de Laravel exige una contraseña para cada usuario, pero las cuentas que vamos a crear no la tendrán. Por ello, vamos a crear una migración que permita que la columna password sea nula:

php artisan make:migration make_password_nullable_on_users_table --table=users

Edita la migración que se ha creado en el directorio database/migrations y agrega este código en el método up:

Schema::table('users', function (Blueprint $table) {
    $table->string('password')->nullable()->change();
});

Podríamos guardar una cadena aleatoria como contraseña, pero quedaría en la tabla algo que parece una credencial sin serlo, así que es más claro usar el valor null. El método change permite modificar una columna que ya existe, tal y como se explica en la documentación de las migraciones de Laravel. Ahora ejecuta la migración:

php artisan migrate

Crea las rutas

Vamos a definir las rutas de la aplicación en el archivo routes/web.php. Debes agregar estas rutas:

use App\Http\Controllers\Auth\LoginController;

Route::redirect('/', '/panel');

Route::middleware('guest')->group(function () {
    Route::get('/login', [LoginController::class, 'create'])->name('login');
    Route::post('/login', [LoginController::class, 'send'])->middleware('throttle:10,1')->name('login.send');
    Route::get('/login/codigo', [LoginController::class, 'code'])->name('login.code');
    Route::post('/login/codigo', [LoginController::class, 'verify'])->middleware('throttle:30,1')->name('login.verify');
    Route::post('/login/codigo/reenviar', [LoginController::class, 'resend'])->middleware('throttle:6,1')->name('login.resend');
});

Route::middleware('auth')->group(function () {
    Route::view('/panel', 'dashboard')->name('dashboard');
    Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
});

Las rutas del grupo guest solo estarán disponibles para quien no haya iniciado sesión. La ruta login muestra el formulario en el que se introduce el email, la ruta login.send envía el código y las rutas login.code y login.verify muestran y procesan el formulario del código. También hemos agregado la ruta login.resend para poder pedir un código nuevo.

Tal y como ves, hemos limitado el número de peticiones por minuto de cada ruta con el middleware throttle. Si quieres saber más acerca de este middleware, consulta el tutorial en el que explico cómo usar Rate Limiters con rutas en Laravel.

Finalmente, las rutas del grupo auth solo estarán disponibles para los usuarios que hayan iniciado sesión. La ruta dashboard mostrará un sencillo panel y la ruta logout cerrará la sesión.

Crea el controlador

Vamos a crear el controlador que gestionará el login. Para ello, ejecuta el siguiente comando:

php artisan make:controller Auth/LoginController

Esto creará el controlador en el directorio app/Http/Controllers/Auth/. Por ahora vamos a agregar los métodos que muestran los formularios y el que cierra la sesión:

public function create(): View
{
    return view('auth.login');
}

public function code(Request $request): View|RedirectResponse
{
    $email = $request->session()->get('login_email');

    return $email ? view('auth.code', ['email' => $email]) : redirect()->route('login');
}

public function logout(Request $request): RedirectResponse
{
    Auth::logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect()->route('login');
}

El método code muestra el formulario del código solamente si hay un email guardado en la sesión. En caso contrario, redirige al usuario al formulario inicial. Dejaremos el resto de métodos para más adelante.

Implementación del login

Una vez tenemos la estructura de la aplicación, vamos a implementar el envío y la verificación de los códigos.

Crea la clase del código

Toda la lógica del código la vamos a agrupar en una única clase, de modo que el controlador solamente tenga que decidir qué hacer con cada respuesta. Ejecuta este comando para crearla:

php artisan make:class Auth/EmailCode

La clase tendrá un método send que genera un código de seis cifras al azar, guarda su hash en la caché durante diez minutos y lo envía por email:

public function send(bool $isNew): void
{
    RateLimiter::hit($this->key('send'), self::MINUTES * 60);
    RateLimiter::hit($this->key('send-day'), 86400);

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

    Cache::put($this->key('code'), ['code' => Hash::make($code), 'tries' => 0], now()->addMinutes(self::MINUTES));

    Mail::to($this->email)->send(new LoginCode($code, $isNew));
}

Hemos guardado el hash del código en lugar del propio código, ya que así nadie que tenga acceso a la caché podrá usarlo para entrar en una cuenta. Para ello usamos la fachada Hash de Laravel, que puedes consultar en la documentación de hashing. Si nunca has usado la caché de Laravel, consulta el tutorial en el que explico cómo usar la caché en Laravel.

Para comprobar el código usaremos el método check, que devuelve un valor distinto según el resultado:

public function check(string $code): string
{
    if (RateLimiter::tooManyAttempts($this->key('failures'), self::FAILURES_PER_DAY)) {
        return self::LOCKED;
    }

    $held = Cache::get($this->key('code'));

    if (! $held) {
        return self::GONE;
    }

    if ($held['tries'] >= self::TRIES) {
        Cache::forget($this->key('code'));

        return self::BURNT;
    }

    if (! Hash::check($code, $held['code'])) {
        $held['tries']++;
        Cache::put($this->key('code'), $held, now()->addMinutes(self::MINUTES));
        RateLimiter::hit($this->key('failures'), 86400);

        return self::WRONG;
    }

    Cache::forget($this->key('code'));
    RateLimiter::clear($this->key('send'));

    return self::OK;
}

Estos son los posibles resultados:

  • OK: El código es correcto. Lo borramos para que no se pueda volver a usar.
  • WRONG: El código es incorrecto. Sumamos un intento.
  • BURNT: Se han agotado los cinco intentos, así que destruimos el código. Seis cifras son un millón de combinaciones, algo que un script podría probar sin problemas si no limitásemos los intentos.
  • GONE: El código ha caducado o nunca se ha pedido.
  • LOCKED: La dirección acumula veinte códigos incorrectos en el día, por lo que queda bloqueada hasta el día siguiente.

La clase también limita los códigos que puede recibir cada dirección: tres cada diez minutos y diez al día. Todos estos límites los gestiona la clase RateLimiter, que se explica en la documentación de rate limiting de Laravel. Puedes consultar el código completo de la clase en GitHub.

Crea el correo

Para enviar el código vamos a crear un mailable de Laravel que use una vista con Markdown. Si quieres saber más acerca de los mailables, consulta la documentación de los emails en Laravel:

php artisan make:mail LoginCode --markdown=mail.login-code

En el método envelope del mailable vamos a incluir también el código en el asunto, ya que mucha gente lo lee en la notificación del móvil sin llegar a abrir el email:

public function envelope(): Envelope
{
    return new Envelope(subject: "{$this->code} es tu código para entrar en ".config('app.name'));
}

Este es el aspecto que tendrá el correo:

Correo con el código de acceso de seis cifras

Hemos enviado el correo directamente en lugar de usar una cola, ya que quien pide el código está delante de la pantalla esperándolo.

Envía el código

Ahora vamos a agregar el widget de Turnstile al formulario de la vista auth/login.blade.php. Laracaptcha incluye un componente con el que podrás hacerlo con una sola línea:

<form method="post" action="{{ route('login.send') }}">
    @csrf

    <label for="email">Email</label>
    <input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username">

    <x-laracaptcha::widget action="login" language="es" />

    <button type="submit">Enviarme el código</button>
</form>

A continuación agrega el método send al controlador, que valida el email y el captcha, envía el código y guarda el email en la sesión:

public function send(Request $request): RedirectResponse
{
    $captcha = Captcha::responseField();

    $data = $request->validate([
        'email' => ['required', 'string', 'email', 'max:255'],
        $captcha => ['required', CaptchaRule::make()],
    ], [
        'email.required' => 'Escribe tu email.',
        'email.email' => 'Ese email no es válido.',
        'email.max' => 'Ese email es demasiado largo.',
        "{$captcha}.required" => 'Completa la verificación de seguridad.',
    ]);

    $email = Str::lower(trim($data['email']));

    if ($error = $this->sendCode($request, $email)) {
        return back()->withInput($request->only('email'))->withErrors(['email' => $error]);
    }

    $request->session()->put('login_email', $email);

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

La regla CaptchaRule es la que comprueba la respuesta del widget con Cloudflare. Si algún día quieres usar reCAPTCHA en lugar de Turnstile, bastará con cambiar la variable CAPTCHA_DRIVER y agregar sus claves, sin tocar el código.

El envío lo hace el método privado sendCode, que antes de enviar nada comprueba los límites de la clase EmailCode y también los de la máquina que hace la petición: seis códigos por hora y veinte al día desde la misma IP. El límite por dirección no detectaría a un bot que escribe una dirección distinta en cada intento:

$origin = sha1((string) $request->ip());

foreach (self::CODES_PER_ORIGIN as $window => $allowed) {
    if (RateLimiter::tooManyAttempts("login:origin:{$window}:{$origin}", $allowed)) {
        return 'Se han enviado demasiados códigos desde aquí. Vuelve a intentarlo más tarde.';
    }
}

Verifica el código

Tras enviar el código, el usuario verá el formulario en el que debe introducirlo:

Formulario para introducir el código de seis cifras

Para procesarlo, agrega el método verify al controlador:

public function verify(Request $request): RedirectResponse
{
    $email = $request->session()->get('login_email');

    if (! $email) {
        return redirect()->route('login');
    }

    $request->validate(
        ['code' => ['required', 'digits:6']],
        ['code.required' => 'Escribe el código.', 'code.digits' => 'El código tiene 6 cifras.'],
    );

    $code = EmailCode::for($email);

    $error = match ($code->check($request->input('code'))) {
        EmailCode::OK => null,
        EmailCode::WRONG => 'Ese no es el código. Te quedan '.$code->triesLeft().' intentos.',
        EmailCode::BURNT => 'Demasiados intentos con ese código. Pide uno nuevo.',
        EmailCode::GONE => 'El código ha caducado. Pide uno nuevo.',
        EmailCode::LOCKED => 'Demasiados códigos incorrectos hoy para este email. Vuelve a intentarlo mañana.',
    };

    if ($error !== null) {
        return back()->withErrors(['code' => $error]);
    }

    $user = User::firstOrCreate(['email' => $email], ['name' => Str::before($email, '@')]);

    if ($user->email_verified_at === null) {
        $user->forceFill(['email_verified_at' => now()])->save();
    }

    Auth::login($user, remember: true);

    $request->session()->forget('login_email');
    $request->session()->regenerate();

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

Cada resultado del método check tiene su propio mensaje de error. Si el código es correcto, buscamos al usuario o lo creamos si no existe, y finalmente iniciamos su sesión con el método Auth::login, que se explica en la documentación de autenticación de Laravel.

Fíjate en que la cuenta se crea cuando el usuario introduce el código correcto y no cuando escribe su email. De lo contrario, cualquiera podría llenar la tabla de usuarios con direcciones de otras personas. Además, la cuenta se marca como verificada, ya que introducir el código que ha llegado al email ya demuestra que la dirección es suya.

Tests

En el repositorio encontrarás diez tests que cubren todo el flujo. Usan Mail::fake() y Captcha::fake(), por lo que no se envía ningún correo ni se hace ninguna petición a Cloudflare:

public function test_a_new_address_gets_a_code_and_the_right_code_creates_a_verified_account(): void
{
    $this->askForCode('Nueva@Example.com ')->assertRedirect('/login/codigo');

    Mail::assertSent(LoginCode::class, fn (LoginCode $mail) => $mail->hasTo('nueva@example.com') && $mail->isNew);
    $this->assertDatabaseMissing('users', ['email' => 'nueva@example.com']);

    $this->post('/login/codigo', ['code' => $this->sentCode('nueva@example.com')])->assertRedirect('/panel');

    $user = User::where('email', 'nueva@example.com')->firstOrFail();
    $this->assertAuthenticatedAs($user);
    $this->assertNotNull($user->email_verified_at);
    $this->assertNull($user->password);
}

Para ejecutarlos, usa este comando:

php artisan test

Ten en cuenta que Laracaptcha rechaza los tokens que ya se han usado, también cuando está en modo fake, así que cada petición de los tests necesita un token distinto.

Finalizando

Con esto ya habrás creado un login sin contraseña en Laravel. Antes de usarlo en producción, tendrás que configurar un servidor SMTP real en las variables MAIL_* del archivo .env, así como tus propias claves de Turnstile. También deberías asegurarte de que el dominio desde el que envías los correos tiene los registros SPF configurados, ya que de lo contrario los códigos podrían acabar en la carpeta de spam. Para ello, consulta el tutorial en el que explico cómo validar una IP para enviar emails de un dominio usando registros SPF.

Como posible mejora, podrías permitir que los usuarios que lo deseen configuren también una contraseña, de modo que puedan elegir entre usarla o recibir un código. En futuros tutoriales veremos cómo crear este mismo login con Livewire y con React.

Este mismo login también lo hemos creado con otras tecnologías, por si trabajas con alguna de ellas:

Recuerda que siempre puedes consultar el código completo de la aplicación en GitHub. Si quieres usar Laracaptcha en otros formularios, consulta la documentación de Laracaptcha o su repositorio en GitHub.

Esto ha sido todo. Espero que este tutorial te haya resultado útil.