Implementa un flujo de acceso por enlace mágico en Laravel con expiración, un solo uso y prácticas de seguridad.
¿Qué es un Magic Link?
Un Magic Link es un enlace temporal y de un solo uso que enviamos al correo del usuario. Al abrirlo, iniciamos sesión sin contraseña. Es ideal para mejorar UX y reducir fricción, manteniendo controles de seguridad adecuados.
Arquitectura del flujo
- El usuario ingresa su email y solicita acceso.
- Generamos un token seguro, lo guardamos con expiración y lo enviamos por correo en un enlace firmado.
- El usuario hace clic en el enlace; validamos token, expiración y un solo uso.
- Si todo está ok, lo autenticamos y consumimos el token.
users
con campo email
.
1) Migración para tokens de Magic Link
Crearemos una tabla para guardar tokens hash, expiración y uso único.
php artisan make:migration create_magic_links_table
public function up(): void
{
Schema::create('magic_links', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token_hash', 64); // sha256
$table->timestamp('expires_at');
$table->timestamp('consumed_at')->nullable();
$table->string('ip')->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->index(['user_id', 'token_hash']);
});
}
2) Modelo Eloquent
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MagicLink extends Model
{
protected $fillable = [
'user_id','token_hash','expires_at','consumed_at','ip','user_agent'
];
protected $casts = [
'expires_at' => 'datetime',
'consumed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return now()->greaterThan($this->expires_at);
}
public function isConsumed(): bool
{
return ! is_null($this->consumed_at);
}
}
3) Rutas
// routes/web.php
use App\Http\Controllers\Auth\MagicLinkController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('/magic-login', [MagicLinkController::class, 'showRequestForm'])->name('magic.show');
Route::post('/magic-login', [MagicLinkController::class, 'sendLink'])
->middleware(['throttle:5,1']) // 5 intentos por minuto
->name('magic.send');
Route::get('/magic-login/verify', [MagicLinkController::class, 'verify'])
->name('magic.verify'); // enlace del email
});
Route::post('/logout', function () {
auth()->logout();
return redirect('/')->with('status', 'Sesión cerrada');
})->name('logout');
4) Controlador
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\MagicLoginLinkMail;
use App\Models\MagicLink;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class MagicLinkController extends Controller
{
public function showRequestForm()
{
return view('auth.magic-login'); // crea una vista simple con campo email
}
public function sendLink(Request $request)
{
$data = $request->validate([
'email' => ['required','email']
]);
$user = User::where('email', $data['email'])->first();
// Para no filtrar existencia de usuarios, devolvemos siempre OK
if (! $user) {
return back()->with('status', 'Si el correo existe, enviaremos un enlace.');
}
// Genera token y guarda hash
$rawToken = Str::random(40); // devuelve al usuario solo en URL
$tokenHash = hash('sha256', $rawToken);
$magic = MagicLink::create([
'user_id' => $user->id,
'token_hash'=> $tokenHash,
'expires_at'=> now()->addMinutes(15),
'ip' => $request->ip(),
'user_agent'=> substr((string) $request->userAgent(), 0, 255),
]);
// Construye URL: /magic-login/verify?token=...&email=...
$url = route('magic.verify', [
'token' => $rawToken,
'email' => $user->email,
]);
// Opcional: firmar o encriptar parámetros si deseas mayor protección
Mail::to($user->email)->send(new MagicLoginLinkMail($url));
return back()->with('status', 'Si el correo existe, enviaremos un enlace.');
}
public function verify(Request $request)
{
$request->validate([
'token' => ['required','string'],
'email' => ['required','email'],
]);
$user = User::where('email', $request->email)->firstOrFail();
$tokenHash = hash('sha256', (string) $request->token);
$magic = MagicLink::where('user_id', $user->id)
->where('token_hash', $tokenHash)
->latest()
->first();
// Validaciones de seguridad
if (! $magic) {
return redirect()->route('magic.show')->withErrors(['token' => 'Token inválido.']);
}
if ($magic->isExpired()) {
return redirect()->route('magic.show')->withErrors(['token' => 'El enlace expiró.']);
}
if ($magic->isConsumed()) {
return redirect()->route('magic.show')->withErrors(['token' => 'El enlace ya fue usado.']);
}
// Marca como usado (un solo uso)
$magic->forceFill(['consumed_at' => now()])->save();
// Autentica al usuario
auth()->login($user, true); // "remember" opcional
return redirect()->intended('/dashboard');
}
}
5) Mailable para enviar el enlace
php artisan make:mail MagicLoginLinkMail --markdown=emails.magic-link
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class MagicLoginLinkMail extends Mailable
{
use Queueable, SerializesModels;
public string $url;
public function __construct(string $url)
{
$this->url = $url;
}
public function build()
{
return $this->subject('Tu enlace de acceso')
->markdown('emails.magic-link');
}
}
Plantilla Markdown: resources/views/emails/magic-link.blade.php
@component('mail::message')
# Acceso sin contraseña
Haz clic en el siguiente botón para iniciar sesión:
@component('mail::button', ['url' => $url])
Iniciar sesión
@endcomponent
Este enlace expira en 15 minutos y puede usarse una sola vez.
Si no solicitaste este enlace, puedes ignorar este correo.
Saludos,
{{ config('app.name') }}
@endcomponent
6) Vista sencilla para solicitar el enlace
resources/views/auth/magic-login.blade.php
<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Magic Login</title></head>
<body>
<h1>Acceso sin contraseña</h1>
@if ($errors->any())
<div>@foreach($errors->all() as $e) <p>{{ $e }}</p> @endforeach</div>
@endif
@if (session('status')) <p>{{ session('status') }}</p> @endif
<form method="post" action="{{ route('magic.send') }}">
@csrf
<input type="email" name="email" placeholder="tu@email.com" required>
<button type="submit">Enviar enlace</button>
</form>
</body>
</html>
7) Seguridad y buenas prácticas
- Expiración corta: 10–15 minutos.
- Un solo uso: marca el token como consumido antes o al mismo tiempo que autenticas.
- Hashea tokens: guarda solo
sha256
en DB, nunca el token plano. - Throttling: limita solicitudes por IP/email (
throttle
middleware). - Dominios/URLs confiables: envía enlaces con tu dominio y verifica
app.url
. - Auditoría: guarda IP y user-agent para trazabilidad.
- Opcional: firma la URL con
URL::signedRoute()
o cifra parámetros sensibles.
8) Opcional: URL firmada
Puedes firmar la URL para evitar manipulación de parámetros:
// Generar URL firmada
$url = URL::temporarySignedRoute(
'magic.verify',
now()->addMinutes(15),
['token' => $rawToken, 'email' => $user->email]
);
// En el controlador verify, añade el middleware 'signed' en la ruta
// Route::get('/magic-login/verify', ...)->middleware('signed');