295 lines
8.3 KiB
Go
295 lines
8.3 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"
|
|
|
|
"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
|
|
}
|