Notificaciones Toast Seguras en Laravel: Guía Completa para Desarrolladores
Como desarrolladores fullstack, sabemos que las notificaciones toast son fundamentales para la experiencia de usuario. Sin embargo, implementarlas correctamente va más allá de simplemente mostrar mensajes. En esta guía, exploraremos cómo crear un sistema robusto de notificaciones toast en Laravel, considerando aspectos de seguridad, rendimiento y mejores prácticas de desarrollo.
¿Por qué importa la implementación correcta?Las notificaciones toast mal implementadas pueden:
- Exponer información sensible en el frontend
- Crear vulnerabilidades XSS
- Degradar el rendimiento de la aplicación
- Generar una experiencia de usuario inconsistente
Primero, creemos un nuevo proyecto Laravel con las dependencias necesarias:
composer create-project laravel/laravel toast-notifications
cd toast-notifications
composer require pusher/pusher-php-server
npm install toastr
Paso 2: Service Provider Personalizado
Creemos un service provider dedicado para manejar las notificaciones:
php artisan make:provider ToastServiceProvider
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View;
use App\Services\ToastService;
class ToastServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton('toast', function ($app) {
return new ToastService();
});
}
public function boot()
{
View::composer('*', function ($view) {
$toasts = session()->get('toasts', []);
$view->with('toasts', $toasts);
});
}
}
Paso 3: Service Class con Validación
<?php
namespace App\Services;
class ToastService
{
private const ALLOWED_TYPES = ['success', 'error', 'warning', 'info'];
private const MAX_MESSAGE_LENGTH = 255;
public function add(string $message, string $type = 'info', array $options = []): void
{
// Sanitización y validación
$message = $this->sanitizeMessage($message);
$type = $this->validateType($type);
$toast = [
'message' => $message,
'type' => $type,
'timestamp' => now()->timestamp,
'options' => $this->sanitizeOptions($options)
];
$toasts = session()->get('toasts', []);
$toasts[] = $toast;
// Limitar número de toasts por sesión
if (count($toasts) > 10) {
array_shift($toasts);
}
session()->flash('toasts', $toasts);
}
private function sanitizeMessage(string $message): string
{
$message = strip_tags($message);
$message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
if (strlen($message) > self::MAX_MESSAGE_LENGTH) {
$message = substr($message, 0, self::MAX_MESSAGE_LENGTH) . '...';
}
return $message;
}
private function validateType(string $type): string
{
return in_array($type, self::ALLOWED_TYPES) ? $type : 'info';
}
private function sanitizeOptions(array $options): array
{
$allowedOptions = ['timeout', 'position', 'showProgress'];
return array_intersect_key($options, array_flip($allowedOptions));
}
}
Paso 4: Middleware de Rate Limiting
Para prevenir spam de notificaciones, creemos un middleware:
php artisan make:middleware ThrottleToastNotifications
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class ThrottleToastNotifications
{
public function handle(Request $request, Closure $next)
{
$key = $request->ip() . ':toast-notifications';
if (RateLimiter::tooManyAttempts($key, 10)) {
return response()->json([
'error' => 'Too many notifications'
], 429);
}
RateLimiter::hit($key, 60); // 10 notificaciones por minuto
return $next($request);
}
}
Paso 5: Blade Component Reutilizable
Creemos un componente Blade para las notificaciones:
php artisan make:component ToastNotifications
<?php
namespace App\View\Components;
use Illuminate\View\Component;
class ToastNotifications extends Component
{
public $toasts;
public function __construct()
{
$this->toasts = session('toasts', []);
}
public function render()
{
return view('components.toast-notifications');
}
}
{{-- resources/views/components/toast-notifications.blade.php --}}
<div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2">
@if(!empty($toasts))
<script>
document.addEventListener('DOMContentLoaded', function() {
const toasts = @json($toasts);
toasts.forEach(function(toast) {
// Configuración de seguridad para Toastr
toastr.options = {
closeButton: true,
debug: false,
newestOnTop: true,
progressBar: true,
positionClass: "toast-top-right",
preventDuplicates: true,
onclick: null,
showDuration: "300",
hideDuration: "1000",
timeOut: toast.options?.timeout || "5000",
extendedTimeOut: "1000",
showEasing: "swing",
hideEasing: "linear",
showMethod: "fadeIn",
hideMethod: "fadeOut",
escapeHtml: true, // Importante para seguridad
tapToDismiss: true
};
// Mostrar notificación según tipo
switch(toast.type) {
case 'success':
toastr.success(toast.message);
break;
case 'error':
toastr.error(toast.message);
break;
case 'warning':
toastr.warning(toast.message);
break;
case 'info':
default:
toastr.info(toast.message);
break;
}
});
});
</script>
@endif
</div>
Paso 6: Facade para Facilitar el Uso
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class Toast extends Facade
{
protected static function getFacadeAccessor()
{
return 'toast';
}
}
Paso 7: Implementación en Controladores
<?php
namespace App\Http\Controllers;
use App\Facades\Toast;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function update(Request $request, User $user)
{
try {
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
]);
$user->update($validated);
Toast::add(
'Usuario actualizado correctamente',
'success',
['timeout' => 3000]
);
return redirect()->back();
} catch (\Exception $e) {
Toast::add(
'Error al actualizar el usuario',
'error'
);
return redirect()->back();
}
}
}
Mejoras de Seguridad Adicionales
Content Security Policy (CSP)
Añade estas directivas a tu CSP para mayor seguridad:
// En app/Http/Middleware/ContentSecurityPolicy.php
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('Content-Security-Policy',
"script-src 'self' 'unsafe-inline' cdnjs.cloudflare.com; " .
"style-src 'self' 'unsafe-inline' cdnjs.cloudflare.com;"
);
return $response;
}
Logging de Seguridad
// En ToastService.php
private function logSuspiciousActivity(string $message): void
{
if ($this->containsSuspiciousContent($message)) {
Log::warning('Suspicious toast message detected', [
'message' => $message,
'ip' => request()->ip(),
'user_id' => auth()->id(),
'timestamp' => now()
]);
}
}
private function containsSuspiciousContent(string $message): bool
{
$suspiciousPatterns = [
'/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/mi',
'/javascript:/i',
'/onload\s*=/i',
'/onerror\s*=/i'
];
foreach ($suspiciousPatterns as $pattern) {
if (preg_match($pattern, $message)) {
return true;
}
}
return false;
}
Testing
Crea tests para asegurar la funcionalidad:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Facades\Toast;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ToastNotificationTest extends TestCase
{
public function test_toast_sanitizes_html_content()
{
$maliciousMessage = '<script>alert("XSS")</script>Hello';
Toast::add($maliciousMessage, 'info');
$toasts = session('toasts');
$this->assertStringNotContainsString('<script>', $toasts[0]['message']);
$this->assertEquals('Hello', $toasts[0]['message']);
}
public function test_toast_limits_message_length()
{
$longMessage = str_repeat('a', 300);
Toast::add($longMessage, 'info');
$toasts = session('toasts');
$this->assertLessThanOrEqual(255, strlen($toasts[0]['message']));
}
}
Optimización de Rendimiento
Lazy Loading del JavaScript
{{-- En tu layout principal --}}
@push('scripts')
<script>
// Cargar Toastr solo cuando sea necesario
@if(!empty(session('toasts')))
if (!window.toastr) {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/js/toastr.min.js';
script.onload = function() {
// Inicializar toasts después de cargar la librería
initializeToasts();
};
document.head.appendChild(script);
} else {
initializeToasts();
}
@endif
</script>
@endpush
Las notificaciones toast son un detalle aparentemente pequeño, pero su correcta implementación marca la diferencia entre una aplicación amateur y una profesional. Al considerar aspectos de seguridad y rendimiento desde el principio, creamos una base sólida para el crecimiento de nuestra aplicación.