php-content/Models/ContentWebhookEndpoint.php
Snider 0120908669 feat(webhooks): P2-082 P2-083 signature verification and delivery logging
P2-082: Webhook Signature Verification
- Add require_signature field, verifySignatureWithDetails()
- Support grace period during secret rotation
- Log signature failures for audit

P2-083: Webhook Delivery Logging
- WebhookDeliveryLogger service for centralised logging
- Track duration, response code, signature verification
- Add getDeliveryStats() and getRecentSignatureFailures()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:22:09 +00:00

481 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Content\Models;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* Webhook endpoint configuration for receiving external content webhooks.
*
* Each workspace can have multiple webhook endpoints configured,
* allowing external CMS systems (WordPress, Ghost, etc.) to push
* content updates to the Content module.
*
* @property int $id
* @property string $uuid
* @property int $workspace_id
* @property string $name
* @property string|null $secret
* @property array|null $allowed_types
* @property bool $is_enabled
* @property int $failure_count
* @property \Carbon\Carbon|null $last_received_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class ContentWebhookEndpoint extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Mod\Content\Database\Factories\ContentWebhookEndpointFactory
{
return \Core\Mod\Content\Database\Factories\ContentWebhookEndpointFactory::new();
}
protected $table = 'content_webhook_endpoints';
protected $fillable = [
'uuid',
'workspace_id',
'name',
'secret',
'require_signature',
'previous_secret',
'secret_rotated_at',
'grace_period_seconds',
'allowed_types',
'is_enabled',
'failure_count',
'last_received_at',
];
protected $casts = [
'allowed_types' => 'array',
'is_enabled' => 'boolean',
'require_signature' => 'boolean',
'failure_count' => 'integer',
'last_received_at' => 'datetime',
'secret' => 'encrypted',
'previous_secret' => 'encrypted',
'secret_rotated_at' => 'datetime',
'grace_period_seconds' => 'integer',
];
protected $hidden = [
'secret',
'previous_secret',
];
/**
* Supported webhook event types.
*/
public const ALLOWED_TYPES = [
// WordPress events
'wordpress.post_created',
'wordpress.post_updated',
'wordpress.post_deleted',
'wordpress.post_published',
'wordpress.post_trashed',
'wordpress.media_uploaded',
// Generic CMS events
'cms.content_created',
'cms.content_updated',
'cms.content_deleted',
'cms.content_published',
// Generic payload (custom integrations)
'generic.payload',
];
/**
* Maximum consecutive failures before auto-disable.
*/
public const MAX_FAILURES = 10;
protected static function boot(): void
{
parent::boot();
static::creating(function (ContentWebhookEndpoint $endpoint) {
if (empty($endpoint->uuid)) {
$endpoint->uuid = (string) Str::uuid();
}
// Generate a secret if not provided
if (empty($endpoint->secret)) {
$endpoint->secret = Str::random(64);
}
// Default to all allowed types if not specified
if (empty($endpoint->allowed_types)) {
$endpoint->allowed_types = self::ALLOWED_TYPES;
}
});
}
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function logs(): HasMany
{
return $this->hasMany(ContentWebhookLog::class, 'endpoint_id');
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
public function scopeEnabled($query)
{
return $query->where('is_enabled', true);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
// -------------------------------------------------------------------------
// State Checks
// -------------------------------------------------------------------------
public function isEnabled(): bool
{
return $this->is_enabled === true;
}
public function isTypeAllowed(string $type): bool
{
// Allow all if no restrictions
if (empty($this->allowed_types)) {
return true;
}
// Check exact match
if (in_array($type, $this->allowed_types, true)) {
return true;
}
// Check prefix match (e.g., 'wordpress.*' matches 'wordpress.post_created')
foreach ($this->allowed_types as $allowedType) {
if (str_ends_with($allowedType, '.*')) {
$prefix = substr($allowedType, 0, -1);
if (str_starts_with($type, $prefix)) {
return true;
}
}
}
return false;
}
public function isCircuitBroken(): bool
{
return $this->failure_count >= self::MAX_FAILURES;
}
// -------------------------------------------------------------------------
// Signature Verification
// -------------------------------------------------------------------------
/**
* Signature verification failure reasons.
*/
public const SIGNATURE_FAILURE_NO_SECRET = 'no_secret_configured';
public const SIGNATURE_FAILURE_NOT_REQUIRED = 'signature_not_required';
public const SIGNATURE_FAILURE_MISSING = 'signature_missing';
public const SIGNATURE_FAILURE_INVALID = 'signature_invalid';
public const SIGNATURE_SUCCESS = 'verified';
public const SIGNATURE_SUCCESS_GRACE = 'verified_grace_period';
public const SIGNATURE_SUCCESS_NOT_REQUIRED = 'not_required';
/**
* Verify webhook signature with detailed result.
*
* Supports multiple signature formats:
* - X-Signature: HMAC-SHA256 signature of the raw body
* - X-Hub-Signature-256: GitHub-style sha256=... format
* - X-WP-Webhook-Signature: WordPress webhook signature
*
* During a grace period after secret rotation, both current and
* previous secrets are accepted to avoid breaking integrations.
*
* @param string $payload The raw request body
* @param string|null $signature The signature from request header
* @return array{verified: bool, reason: string} Verification result with reason
*/
public function verifySignatureWithDetails(string $payload, ?string $signature): array
{
// Check if signature is required
$requireSignature = $this->require_signature ?? true;
// If no secret configured
if (empty($this->secret)) {
if ($requireSignature) {
// Security: reject if signature is required but no secret configured
return [
'verified' => false,
'reason' => self::SIGNATURE_FAILURE_NO_SECRET,
];
}
// Signature explicitly not required - allow through but log
return [
'verified' => true,
'reason' => self::SIGNATURE_SUCCESS_NOT_REQUIRED,
];
}
// Signature required when secret is set
if (empty($signature)) {
return [
'verified' => false,
'reason' => self::SIGNATURE_FAILURE_MISSING,
];
}
// Normalise signature (handle sha256=... format)
$normalised = $signature;
if (str_starts_with($signature, 'sha256=')) {
$normalised = substr($signature, 7);
}
// Check against current secret
$expectedSignature = hash_hmac('sha256', $payload, $this->secret);
if (hash_equals($expectedSignature, $normalised)) {
return [
'verified' => true,
'reason' => self::SIGNATURE_SUCCESS,
];
}
// Check against previous secret if in grace period
if ($this->isInGracePeriod() && ! empty($this->previous_secret)) {
$previousExpectedSignature = hash_hmac('sha256', $payload, $this->previous_secret);
if (hash_equals($previousExpectedSignature, $normalised)) {
return [
'verified' => true,
'reason' => self::SIGNATURE_SUCCESS_GRACE,
];
}
}
return [
'verified' => false,
'reason' => self::SIGNATURE_FAILURE_INVALID,
];
}
/**
* Verify webhook signature.
*
* Supports multiple signature formats:
* - X-Signature: HMAC-SHA256 signature of the raw body
* - X-Hub-Signature-256: GitHub-style sha256=... format
* - X-WP-Webhook-Signature: WordPress webhook signature
*
* During a grace period after secret rotation, both current and
* previous secrets are accepted to avoid breaking integrations.
*
* @deprecated Use verifySignatureWithDetails() for detailed failure reasons
*/
public function verifySignature(string $payload, ?string $signature): bool
{
return $this->verifySignatureWithDetails($payload, $signature)['verified'];
}
/**
* Check if this endpoint requires signature verification.
*/
public function requiresSignature(): bool
{
return $this->require_signature ?? true;
}
// -------------------------------------------------------------------------
// Status Management
// -------------------------------------------------------------------------
public function incrementFailureCount(): void
{
$this->increment('failure_count');
// Auto-disable after too many failures (circuit breaker)
if ($this->failure_count >= self::MAX_FAILURES) {
$this->update(['is_enabled' => false]);
}
}
public function resetFailureCount(): void
{
$this->update([
'failure_count' => 0,
'last_received_at' => now(),
]);
}
public function markReceived(): void
{
$this->update(['last_received_at' => now()]);
}
// -------------------------------------------------------------------------
// URL Generation
// -------------------------------------------------------------------------
/**
* Get the webhook endpoint URL.
*/
public function getEndpointUrl(): string
{
return route('api.content.webhooks.receive', ['endpoint' => $this->uuid]);
}
/**
* Regenerate the secret and return the new value.
*/
public function regenerateSecret(): string
{
$newSecret = Str::random(64);
$this->update(['secret' => $newSecret]);
return $newSecret;
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Get Flux badge colour for enabled status.
*/
public function getStatusColorAttribute(): string
{
if (! $this->is_enabled) {
return 'zinc';
}
if ($this->isCircuitBroken()) {
return 'red';
}
if ($this->failure_count > 0) {
return 'yellow';
}
return 'green';
}
/**
* Get status label.
*/
public function getStatusLabelAttribute(): string
{
if (! $this->is_enabled) {
return 'Disabled';
}
if ($this->isCircuitBroken()) {
return 'Circuit Open';
}
if ($this->failure_count > 0) {
return "Active ({$this->failure_count} failures)";
}
return 'Active';
}
/**
* Get icon for status.
*/
public function getStatusIconAttribute(): string
{
if (! $this->is_enabled) {
return 'pause-circle';
}
if ($this->isCircuitBroken()) {
return 'exclamation-triangle';
}
return 'check-circle';
}
// -------------------------------------------------------------------------
// Secret Rotation Methods
// -------------------------------------------------------------------------
/**
* Check if the webhook is currently in a grace period.
*/
public function isInGracePeriod(): bool
{
if (empty($this->secret_rotated_at)) {
return false;
}
$rotatedAt = Carbon::parse($this->secret_rotated_at);
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
return now()->isBefore($graceEndsAt);
}
/**
* Get the time remaining in the grace period.
*/
public function getGraceTimeRemainingAttribute(): ?int
{
if (! $this->isInGracePeriod()) {
return null;
}
$rotatedAt = Carbon::parse($this->secret_rotated_at);
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
return (int) now()->diffInSeconds($graceEndsAt, false);
}
/**
* Get when the grace period ends.
*/
public function getGraceEndsAtAttribute(): ?Carbon
{
if (empty($this->secret_rotated_at)) {
return null;
}
$rotatedAt = Carbon::parse($this->secret_rotated_at);
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
return $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
}
}