Autenticación Passkey en Laravel: WebAuthn Seguro
Introducción a la autenticación Passkey
La autenticación basada en contraseñas es uno de los mayores problemas de seguridad en la web moderna. Los usuarios reutilizan contraseñas débiles, sufren ataques de phishing y los desarrolladores deben gestionar bases de datos de credenciales cifradas. Laravel acaba de lanzar soporte nativo para Passkey, una alternativa revolucionaria que elimina las contraseñas tradicionales.
Los Passkey utilizan WebAuthn, un estándar W3C que permite autenticación biométrica (Face ID, Touch ID, Windows Hello) y llaves de seguridad físicas. En lugar de confiar en algo que sabes (contraseña), confías en algo que tienes (dispositivo) o algo que eres (biometría).
Este artículo te mostrará cómo implementar Passkey en Laravel desde cero.
¿Qué es WebAuthn y por qué importa?
WebAuthn es un protocolo de autenticación basado en criptografía de clave pública. El proceso funciona así:
-
Registro: El dispositivo crea un par de claves (pública/privada). La clave privada nunca abandona el dispositivo. Solo la clave pública se envía al servidor.
-
Autenticación: Cuando el usuario intenta iniciar sesión, el servidor envía un desafío. El dispositivo firma ese desafío con la clave privada. El servidor verifica la firma con la clave pública.
Ventajas clave:
- No hay contraseñas para robar
- Imposible de hacer phishing (vinculado al dominio)
- Compatible con biometría nativa del dispositivo
- Funciona offline en muchos casos
- Retrocompatible con navegadores modernos
Instalación del paquete Passkey de Laravel
Laravel proporciona un paquete first-party laravel/passkey para manejar toda la lógica de WebAuthn.
composer require laravel/passkey
Publica los assets y configuración:
php artisan passkey:install
Esto crea:
- Tablas de migración para almacenar Passkeys
- Configuración en
config/passkey.php - Vistas y componentes necesarios
Ejecuta las migraciones:
php artisan migrate
Configuración Inicial
Abre config/passkey.php:
<?php
return [
'driver' => env('PASSKEY_DRIVER', 'eloquent'),
'table' => 'passkeys',
'model' => App\Models\Passkey::class,
'user_model' => App\Models\User::class,
// RP ID debe coincidir con tu dominio
'rp_id' => env('PASSKEY_RP_ID', parse_url(env('APP_URL'), PHP_URL_HOST)),
// Nombre de la aplicación que verá el usuario
'rp_name' => env('APP_NAME', 'Mi Aplicación'),
// Origen válido para WebAuthn
'origins' => [
env('APP_URL'),
],
];
Configura en .env:
PASSKEY_RP_ID=tudominio.com
PASSKEY_DRIVER=eloquent
Preparar el Modelo User
Primero, asegúrate de que el modelo User tenga una relación con los Passkeys:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\LaravelPasskey\HasPasskeys;
class User extends Authenticatable
{
use HasPasskeys;
protected $fillable = [
'name',
'email',
'password', // Todavía podemos mantener contraseña como fallback
];
public function passkeys()
{
return $this->hasMany(Passkey::class);
}
}
El trait HasPasskeys proporciona métodos para gestionar Passkeys.
Crear Rutas para Registro de Passkey
En routes/web.php:
<?php
use Illuminate\Support\Facades\Route;
use Laravel\Passkey\Http\Controllers\PasskeyController;
use App\Http\Controllers\Auth\PasskeyRegistrationController;
Route::middleware('auth')->group(function () {
// Mostrar formulario para registrar nuevo Passkey
Route::get('/account/passkeys', [PasskeyRegistrationController::class, 'show'])
->name('passkey.show');
// Iniciar proceso de registro de Passkey
Route::post('/account/passkeys', [PasskeyController::class, 'store'])
->name('passkey.store');
// Eliminar un Passkey
Route::delete('/account/passkeys/{passkey}', [PasskeyController::class, 'destroy'])
->name('passkey.destroy');
});
// Rutas de autenticación con Passkey
Route::get('/login/passkey', [PasskeyController::class, 'loginView'])
->name('passkey.login');
Route::post('/login/passkey', [PasskeyController::class, 'authenticate'])
->name('passkey.authenticate');
Controlador de Registro de Passkey
Crea app/Http/Controllers/Auth/PasskeyRegistrationController.php:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class PasskeyRegistrationController extends Controller
{
/**
* Mostrar la vista para registrar un nuevo Passkey
*/
public function show(Request $request): View
{
$user = $request->user();
$passkeys = $user->passkeys;
return view('auth.passkey-register', [
'passkeys' => $passkeys,
'user' => $user,
]);
}
}
Vista de Registro de Passkey
Crea resources/views/auth/passkey-register.blade.php:
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl font-bold mb-6">Gestionar Passkeys</h1>
<!-- Mensaje de éxito -->
@if (session('success'))
<div class="mb-4 p-3 bg-green-100 text-green-700 rounded">
{{ session('success') }}
</div>
@endif
<!-- Botón para registrar nuevo Passkey -->
<button
id="register-passkey"
type="button"
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 mb-6"
>
Registrar Nuevo Passkey
</button>
<!-- Lista de Passkeys registrados -->
<div class="mt-6">
<h2 class="text-lg font-semibold mb-3">Tus Passkeys</h2>
@forelse ($passkeys as $passkey)
<div class="flex justify-between items-center mb-3 p-3 border rounded">
<div>
<p class="font-medium">{{ $passkey->name ?? 'Passkey sin nombre' }}</p>
<p class="text-sm text-gray-500">
Registrado: {{ $passkey->created_at->format('d/m/Y H:i') }}
</p>
</div>
<form
action="{{ route('passkey.destroy', $passkey) }}"
method="POST"
onsubmit="return confirm('¿Eliminar este Passkey?')"
>
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">
Eliminar
</button>
</form>
</div>
@empty
<p class="text-gray-500">Aún no has registrado ningún Passkey</p>
@endforelse
</div>
</div>
</div>
<script>
document.getElementById('register-passkey').addEventListener('click', async () => {
try {
// Solicitar opciones de registro al servidor
const response = await fetch('/passkey/register/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
}
});
const options = await response.json();
// Crear el Passkey
const credential = await navigator.credentials.create({
publicKey: options
});
if (credential) {
// Enviar el Passkey al servidor
await fetch('{{ route("passkey.store") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(credential)
});
alert('¡Passkey registrado exitosamente!');
location.reload();
}
} catch (error) {
console.error('Error al registrar Passkey:', error);
alert('No se pudo registrar el Passkey. Verifica que tu dispositivo sea compatible.');
}
});
</script>
Vista de Login con Passkey
Crea resources/views/auth/passkey-login.blade.php:
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl font-bold mb-6 text-center">Iniciar Sesión con Passkey</h1>
@if ($errors->any())
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
@endif
<button
id="authenticate-passkey"
type="button"
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 text-lg font-semibold"
>
Iniciar Sesión
</button>
<p class="text-center text-gray-600 mt-4">
<a href="{{ route('login') }}" class="text-blue-600 hover:underline">
¿Prefieres usar contraseña?
</a>
</p>
</div>
</div>
<script>
document.getElementById('authenticate-passkey').addEventListener('click', async () => {
try {
// Obtener opciones de autenticación
const response = await fetch('/login/passkey/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const options = await response.json();
// Pedir al usuario que confirme con biometría
const assertion = await navigator.credentials.get({
publicKey: options
});
if (assertion) {
// Enviar respuesta al servidor
const verifyResponse = await fetch('{{ route("passkey.authenticate") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(assertion)
});
if (verifyResponse.ok) {
window.location.href = '/dashboard';
} else {
alert('No se pudo autenticar. Intenta de nuevo.');
}
}
} catch (error) {
console.error('Error de autenticación:', error);
alert('Error al iniciar sesión. Asegúrate de usar un dispositivo compatible.');
}
});
</script>
Flujo de Autenticación Completo
El paquete de Laravel maneja internamente la lógica criptográfica. El flujo es:
1. Usuario hace clic en "Iniciar Sesión"
↓
2. Servidor genera un desafío aleatorio
↓
3. Navegador solicita al dispositivo que firme el desafío
↓
4. Dispositivo autentica con biometría/PIN y devuelve la firma
↓
5. Servidor verifica la firma con la clave pública almacenada
↓
6. Si es válida, se inicia la sesión
Manejo de Errores Comunes
Dispositivo no compatible
// En tu controlador
try {
$passkey = $user->passkeys()->create([
'credential_id' => $credentialId,
'public_key' => $publicKey,
]);
} catch (\Exception $e) {
return back()->withErrors(['device' => 'Tu dispositivo no soporta Passkey']);
}
Usuario sin Passkey registrado
public function authenticate(Request $request)
{
$user = User::where('email', $request->email)->first();
if (!$user || $user->passkeys()->count() === 0) {
return back()->withErrors(['passkey' => 'Usuario no tiene Passkey registrado']);
}
// Continuar con verificación...
}
Combinando Passkey con Autenticación Tradicional
Es recomendable permitir tanto Passkey como contraseña durante una fase de transición:
// En tu controlador de login
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
// Opción 1: Passkey
if ($user && $user->passkeys()->count() > 0) {
return redirect()->route('passkey.login');
}
// Opción 2: Contraseña
if (Auth::attempt($request->only('email', 'password'))) {
return redirect()->intended('/dashboard');
}
return back()->withErrors(['email' => 'Credenciales inválidas']);
}
Seguridad y Buenas Prácticas
1. Valida el origen El servidor debe verificar que la solicitud viene del dominio correcto:
if (!in_array($_SERVER['HTTP_ORIGIN'], config('passkey.origins'))) {
abort(403, 'Origen no autorizado');
}
2. Rate limiting Protege contra ataques de fuerza bruta:
Route::post('/login/passkey', [PasskeyController::class, 'authenticate'])
->middleware('throttle:5,1') // 5 intentos por minuto
->name('passkey.authenticate');
3. Backup de Passkeys Permite que los usuarios registren múltiples Passkeys:
// Un usuario puede tener varios Passkeys en diferentes dispositivos
$user->pass