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" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // WebhookService gère les webhooks type WebhookService struct { db *gorm.DB logger *zap.Logger secret string client *http.Client } // 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"` } // NewWebhookService crée un nouveau service de webhooks func NewWebhookService(db *gorm.DB, logger *zap.Logger, secret string) *WebhookService { return &WebhookService{ db: 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) { // 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.db.WithContext(ctx).Create(webhook).Error; 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) // 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 var webhooks []models.Webhook query := s.db.WithContext(ctx).Where("active = ? AND events @> ARRAY[?]", true, event) if userID != nil { query = query.Where("user_id = ?", *userID) } if err := query.Find(&webhooks).Error; 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) { var webhooks []models.Webhook if err := s.db.WithContext(ctx). Where("user_id = ?", userID). Find(&webhooks).Error; 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) { var webhook models.Webhook if err := s.db.WithContext(ctx). Where("id = ? AND user_id = ?", webhookID, userID). First(&webhook).Error; 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 { result := s.db.WithContext(ctx). Where("id = ? AND user_id = ?", webhookID, userID). Delete(&models.Webhook{}) if result.Error != nil { return fmt.Errorf("failed to delete webhook: %w", result.Error) } if result.RowsAffected == 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") } var webhook models.Webhook if err := s.db.WithContext(ctx). Where("api_key = ? AND active = ?", apiKey, true). First(&webhook).Error; 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.db.WithContext(ctx).Save(webhook).Error; 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 }