veza/veza-backend-api/internal/services/webhook_service.go

304 lines
9.1 KiB
Go

package services
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"veza-backend-api/internal/models"
"veza-backend-api/internal/repositories"
"veza-backend-api/internal/validators"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// WebhookService gère les webhooks
type WebhookService struct {
repo WebhookRepositoryInterface
logger *zap.Logger
secret string
client *http.Client
}
// WebhookRepositoryInterface defines webhook data access (allows injection from repositories package)
type WebhookRepositoryInterface interface {
Create(ctx context.Context, webhook *models.Webhook) error
ListByUserID(ctx context.Context, userID uuid.UUID) ([]models.Webhook, error)
GetByIDAndUserID(ctx context.Context, webhookID, userID uuid.UUID) (*models.Webhook, error)
FindActiveByEvent(ctx context.Context, event string, userID *uuid.UUID) ([]models.Webhook, error)
Update(ctx context.Context, webhook *models.Webhook) error
Delete(ctx context.Context, webhookID, userID uuid.UUID) (int64, error)
GetByAPIKey(ctx context.Context, apiKey string) (*models.Webhook, error)
}
// WebhookPayload représente le payload d'un webhook
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
// IsURLSafe validates that a webhook URL does not target internal or private resources (SSRF protection).
// Delegates to validators.ValidateWebhookURL.
func IsURLSafe(rawURL string) error {
return validators.ValidateWebhookURL(rawURL)
}
// NewWebhookService crée un nouveau service de webhooks
func NewWebhookService(db *gorm.DB, logger *zap.Logger, secret string) *WebhookService {
return &WebhookService{
repo: repositories.NewWebhookRepository(db),
logger: logger,
secret: secret,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// GenerateAPIKey génère une clé API sécurisée pour un webhook (BE-SEC-012)
func (s *WebhookService) GenerateAPIKey() (string, error) {
// Générer 32 bytes aléatoires
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encoder en base64 URL-safe et préfixer avec "whk_" pour identifier les clés webhook
apiKey := "whk_" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes)
return apiKey, nil
}
// RegisterWebhook enregistre une nouvelle URL de webhook avec génération de clé API (BE-SEC-012)
func (s *WebhookService) RegisterWebhook(ctx context.Context, userID uuid.UUID, url string, events []string) (*models.Webhook, error) {
// SSRF protection: validate URL before registering
if err := IsURLSafe(url); err != nil {
return nil, fmt.Errorf("webhook URL rejected: %w", err)
}
// Générer une clé API unique
apiKey, err := s.GenerateAPIKey()
if err != nil {
return nil, fmt.Errorf("failed to generate API key: %w", err)
}
webhook := &models.Webhook{
UserID: userID,
URL: url,
Events: events,
Active: true,
APIKey: apiKey,
CreatedAt: time.Now(),
}
if err := s.repo.Create(ctx, webhook); err != nil {
return nil, fmt.Errorf("failed to register webhook: %w", err)
}
s.logger.Info("Webhook registered",
zap.String("user_id", userID.String()),
zap.String("url", url),
zap.Strings("events", events),
zap.String("api_key_prefix", apiKey[:8]+"..."))
return webhook, nil
}
// DeliverWebhook envoie un webhook avec retry et signature HMAC
func (s *WebhookService) DeliverWebhook(ctx context.Context, webhook *models.Webhook, event string, data map[string]interface{}) error {
payload := WebhookPayload{
Event: event,
Timestamp: time.Now(),
Data: data,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Générer signature HMAC
signature := s.generateSignature(jsonData)
// SSRF protection: validate URL before delivering
if err := IsURLSafe(webhook.URL); err != nil {
return fmt.Errorf("webhook URL rejected: %w", err)
}
// Créer la requête HTTP
req, err := http.NewRequestWithContext(ctx, "POST", webhook.URL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Veza-Signature", signature)
req.Header.Set("X-Veza-Event", event)
req.Header.Set("X-Veza-Timestamp", payload.Timestamp.Format(time.RFC3339))
// FIX #11: Propager le request_id depuis le contexte si disponible
if requestID := extractRequestIDFromContext(ctx); requestID != "" {
req.Header.Set("X-Request-ID", requestID)
}
// Envoyer avec retry
maxRetries := 3
backoff := time.Second
for i := 0; i < maxRetries; i++ {
resp, err := s.client.Do(req)
if err != nil {
s.logger.Warn("Webhook delivery failed, retrying",
zap.Int("attempt", i+1),
zap.Error(err))
if i < maxRetries-1 {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
continue
}
return fmt.Errorf("webhook delivery failed after %d attempts: %w", maxRetries, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.logger.Info("Webhook delivered successfully",
zap.String("url", webhook.URL),
zap.String("event", event))
return nil
}
s.logger.Warn("Webhook returned non-200 status",
zap.String("url", webhook.URL),
zap.Int("status", resp.StatusCode))
}
return fmt.Errorf("webhook delivery failed")
}
// generateSignature génère une signature HMAC-SHA256
func (s *WebhookService) generateSignature(payload []byte) string {
mac := hmac.New(sha256.New, []byte(s.secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifySignature vérifie une signature HMAC
func (s *WebhookService) VerifySignature(signature string, payload []byte) bool {
expected := s.generateSignature(payload)
return hmac.Equal([]byte(signature), []byte(expected))
}
// TriggerEvent déclenche un événement pour tous les webhooks concernés
func (s *WebhookService) TriggerEvent(ctx context.Context, event string, data map[string]interface{}, userID *uuid.UUID) error {
// Récupérer les webhooks actifs pour cet événement
webhooks, err := s.repo.FindActiveByEvent(ctx, event, userID)
if err != nil {
return fmt.Errorf("failed to fetch webhooks: %w", err)
}
// Envoyer les webhooks en async
for _, webhook := range webhooks {
go func(w models.Webhook) {
if err := s.DeliverWebhook(ctx, &w, event, data); err != nil {
s.logger.Error("Failed to deliver webhook",
zap.Error(err),
zap.String("url", w.URL),
zap.String("event", event))
}
}(webhook)
}
return nil
}
// ListWebhooks liste les webhooks d'un utilisateur
func (s *WebhookService) ListWebhooks(ctx context.Context, userID uuid.UUID) ([]models.Webhook, error) {
webhooks, err := s.repo.ListByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to list webhooks: %w", err)
}
return webhooks, nil
}
// GetWebhook récupère un webhook par son ID et userID
func (s *WebhookService) GetWebhook(ctx context.Context, webhookID, userID uuid.UUID) (*models.Webhook, error) {
webhook, err := s.repo.GetByIDAndUserID(ctx, webhookID, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("webhook not found")
}
return nil, fmt.Errorf("failed to get webhook: %w", err)
}
return webhook, nil
}
// DeleteWebhook supprime un webhook
func (s *WebhookService) DeleteWebhook(ctx context.Context, webhookID, userID uuid.UUID) error {
rows, err := s.repo.Delete(ctx, webhookID, userID)
if err != nil {
return fmt.Errorf("failed to delete webhook: %w", err)
}
if rows == 0 {
return fmt.Errorf("webhook not found")
}
return nil
}
// ValidateAPIKey valide une clé API et retourne le webhook associé (BE-SEC-012)
func (s *WebhookService) ValidateAPIKey(ctx context.Context, apiKey string) (*models.Webhook, error) {
if !strings.HasPrefix(apiKey, "whk_") {
return nil, fmt.Errorf("invalid API key format")
}
webhook, err := s.repo.GetByAPIKey(ctx, apiKey)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("invalid API key")
}
return nil, fmt.Errorf("failed to validate API key: %w", err)
}
return webhook, nil
}
// RegenerateAPIKey régénère la clé API d'un webhook (BE-SEC-012)
func (s *WebhookService) RegenerateAPIKey(ctx context.Context, webhookID, userID uuid.UUID) (string, error) {
// Vérifier que le webhook existe et appartient à l'utilisateur
webhook, err := s.GetWebhook(ctx, webhookID, userID)
if err != nil {
return "", err
}
// Générer une nouvelle clé API
newAPIKey, err := s.GenerateAPIKey()
if err != nil {
return "", fmt.Errorf("failed to generate API key: %w", err)
}
// Mettre à jour le webhook
webhook.APIKey = newAPIKey
webhook.UpdatedAt = time.Now()
if err := s.repo.Update(ctx, webhook); err != nil {
return "", fmt.Errorf("failed to update webhook: %w", err)
}
s.logger.Info("Webhook API key regenerated",
zap.String("webhook_id", webhookID.String()),
zap.String("user_id", userID.String()))
return newAPIKey, nil
}