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 }