feat(whatsapp): validar firma de Meta en el webhook y restaurar el menú del módulo
- POST /webhooks exige X-Hub-Signature-256 (HMAC-SHA256 del cuerpo crudo); falla cerrado si app_secret no está configurado - nueva clave app_secret en whatsapp_system_config, editable desde Configuración Bot - menú lateral de WhatsApp visible de nuevo (Correo/IMAP sigue en Chat/Bot) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5e0c0367d9
commit
e3ad0a423a
@@ -45,6 +45,12 @@ class WhatsappWebhookController extends Controller
|
|||||||
|
|
||||||
public function handle(Request $request)
|
public function handle(Request $request)
|
||||||
{
|
{
|
||||||
|
// Solo Meta puede publicar aquí: firma HMAC-SHA256 del cuerpo crudo con el App Secret.
|
||||||
|
// Falla cerrado — sin secreto configurado no se procesa nada.
|
||||||
|
if (! $this->signatureValid($request)) {
|
||||||
|
return response()->json(['status' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $request->all();
|
$payload = $request->all();
|
||||||
|
|
||||||
// Loguear el payload entrante antes de procesar
|
// Loguear el payload entrante antes de procesar
|
||||||
@@ -66,6 +72,36 @@ class WhatsappWebhookController extends Controller
|
|||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Verificación de la firma X-Hub-Signature-256 de Meta
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function signatureValid(Request $request): bool
|
||||||
|
{
|
||||||
|
$secret = WhatsappSystemConfig::get('app_secret');
|
||||||
|
|
||||||
|
if ($secret === '') {
|
||||||
|
Log::error('[WhatsApp Webhook] app_secret sin configurar; payload rechazado.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = $request->header('X-Hub-Signature-256', '');
|
||||||
|
|
||||||
|
if (! str_starts_with($header, 'sha256=')) {
|
||||||
|
Log::warning('[WhatsApp Webhook] Falta cabecera X-Hub-Signature-256.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$esperada = hash_hmac('sha256', $request->getContent(), $secret);
|
||||||
|
|
||||||
|
if (! hash_equals($esperada, substr($header, 7))) {
|
||||||
|
Log::warning('[WhatsApp Webhook] Firma inválida; payload rechazado.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// Extracción y enrutamiento del payload
|
// Extracción y enrutamiento del payload
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ShowConfiguracionBot extends Component
|
|||||||
public string $phone_number_id = '';
|
public string $phone_number_id = '';
|
||||||
public string $whatsapp_api_url = '';
|
public string $whatsapp_api_url = '';
|
||||||
public string $webhook_verify_token = '';
|
public string $webhook_verify_token = '';
|
||||||
|
public string $app_secret = '';
|
||||||
public string $bot_enabled = '1';
|
public string $bot_enabled = '1';
|
||||||
public string $welcome_message = '';
|
public string $welcome_message = '';
|
||||||
public string $default_no_match = '';
|
public string $default_no_match = '';
|
||||||
@@ -27,6 +28,7 @@ class ShowConfiguracionBot extends Component
|
|||||||
'phone_number_id' => 'required|string',
|
'phone_number_id' => 'required|string',
|
||||||
'whatsapp_api_url' => 'required|url',
|
'whatsapp_api_url' => 'required|url',
|
||||||
'webhook_verify_token' => 'required|string',
|
'webhook_verify_token' => 'required|string',
|
||||||
|
'app_secret' => 'required|string',
|
||||||
'welcome_message' => 'required|string|max:1000',
|
'welcome_message' => 'required|string|max:1000',
|
||||||
'default_no_match' => 'required|string|max:500',
|
'default_no_match' => 'required|string|max:500',
|
||||||
'advisor_message' => 'required|string|max:500',
|
'advisor_message' => 'required|string|max:500',
|
||||||
@@ -36,7 +38,7 @@ class ShowConfiguracionBot extends Component
|
|||||||
{
|
{
|
||||||
$keys = [
|
$keys = [
|
||||||
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
|
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
|
||||||
'webhook_verify_token', 'bot_enabled', 'welcome_message',
|
'webhook_verify_token', 'app_secret', 'bot_enabled', 'welcome_message',
|
||||||
'default_no_match', 'advisor_message', 'business_hours_enabled',
|
'default_no_match', 'advisor_message', 'business_hours_enabled',
|
||||||
'business_hours_start', 'business_hours_end',
|
'business_hours_start', 'business_hours_end',
|
||||||
];
|
];
|
||||||
@@ -52,7 +54,7 @@ class ShowConfiguracionBot extends Component
|
|||||||
|
|
||||||
$keys = [
|
$keys = [
|
||||||
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
|
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
|
||||||
'webhook_verify_token', 'bot_enabled', 'welcome_message',
|
'webhook_verify_token', 'app_secret', 'bot_enabled', 'welcome_message',
|
||||||
'default_no_match', 'advisor_message', 'business_hours_enabled',
|
'default_no_match', 'advisor_message', 'business_hours_enabled',
|
||||||
'business_hours_start', 'business_hours_end',
|
'business_hours_start', 'business_hours_end',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('whatsapp_system_config')->updateOrInsert(
|
||||||
|
['config_key' => 'app_secret'],
|
||||||
|
[
|
||||||
|
'config_value' => '',
|
||||||
|
'description' => 'App Secret de Meta, firma los webhooks entrantes (X-Hub-Signature-256)',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('whatsapp_system_config')->where('config_key', 'app_secret')->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -252,7 +252,44 @@
|
|||||||
{{-- Módulo WhatsApp (solo admins) --}}
|
{{-- Módulo WhatsApp (solo admins) --}}
|
||||||
@if (in_array(auth()->user()->rol->nombre, ['super', 'administrador']))
|
@if (in_array(auth()->user()->rol->nombre, ['super', 'administrador']))
|
||||||
<div x-data="{ openWa: false }" class="space-y-1">
|
<div x-data="{ openWa: false }" class="space-y-1">
|
||||||
{{-- Módulo WhatsApp oculto --}}
|
<button @click="openWa = !openWa"
|
||||||
|
class="flex items-center justify-between w-full py-2 px-3 rounded text-white hover:bg-white/10 focus:outline-none">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/><path d="M12 0C5.373 0 0 5.373 0 12c0 2.127.558 4.122 1.532 5.857L.057 23.786a.5.5 0 0 0 .629.628l5.963-1.467A11.944 11.944 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 22c-1.88 0-3.638-.52-5.145-1.42l-.369-.22-3.818.94.974-3.782-.239-.381A9.944 9.944 0 0 1 2 12c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10z"/>
|
||||||
|
</svg>
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</div>
|
||||||
|
<svg :class="openWa ? 'rotate-180' : ''" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="openWa" x-transition class="space-y-1">
|
||||||
|
<a href="{{ route('whatsapp.conversaciones') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.conversaciones') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||||
|
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg>
|
||||||
|
<span>Conversaciones</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('whatsapp.menus') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.menus') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||||
|
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12h14M5 6h14M5 18h14" /></svg>
|
||||||
|
<span>Menús del Bot</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('whatsapp.plantillas') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.plantillas') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||||
|
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z" /></svg>
|
||||||
|
<span>Plantillas</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('whatsapp.programados') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.programados') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||||
|
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
|
||||||
|
<span>Mensajes Programados</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('whatsapp.configuracion') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.configuracion') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||||
|
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
|
||||||
|
<span>Configuración Bot</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('whatsapp.logs') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.logs') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||||
|
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /></svg>
|
||||||
|
<span>Logs Webhook</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
|
||||||
@error('webhook_verify_token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
@error('webhook_verify_token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">App Secret *</label>
|
||||||
|
<input wire:model="app_secret" type="text"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Firma los webhooks entrantes. Sin este valor el webhook rechaza todo.</p>
|
||||||
|
@error('app_secret') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Control del bot --}}
|
{{-- Control del bot --}}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
// ponytail: check suelto, corre con `php tests/whatsapp_signature_check.php` sin phpunit ni vendor.
|
||||||
|
// Verifica el contrato de firma que aplica WhatsappWebhookController::signatureValid().
|
||||||
|
|
||||||
|
$secret = 'secreto-de-prueba';
|
||||||
|
$payload = '{"entry":[{"changes":[{"field":"messages"}]}]}';
|
||||||
|
|
||||||
|
$firmar = fn(string $body, string $sec) => 'sha256=' . hash_hmac('sha256', $body, $sec);
|
||||||
|
|
||||||
|
$valida = function (string $header, string $body, string $sec): bool {
|
||||||
|
if ($sec === '' || ! str_starts_with($header, 'sha256=')) return false;
|
||||||
|
return hash_equals(hash_hmac('sha256', $body, $sec), substr($header, 7));
|
||||||
|
};
|
||||||
|
|
||||||
|
assert($valida($firmar($payload, $secret), $payload, $secret) === true, 'firma correcta debe pasar');
|
||||||
|
assert($valida($firmar($payload, $secret), $payload . ' ', $secret) === false, 'cuerpo alterado debe fallar');
|
||||||
|
assert($valida($firmar($payload, 'otro'), $payload, $secret) === false, 'secreto distinto debe fallar');
|
||||||
|
assert($valida($firmar($payload, $secret), $payload, '') === false, 'sin secreto debe fallar cerrado');
|
||||||
|
assert($valida('', $payload, $secret) === false, 'sin cabecera debe fallar');
|
||||||
|
assert($valida(hash_hmac('sha256', $payload, $secret), $payload, $secret) === false, 'sin prefijo sha256= debe fallar');
|
||||||
|
|
||||||
|
echo "OK: 6 casos de firma\n";
|
||||||
Reference in New Issue
Block a user