* fix(security): harden auth and credential handling (HH-444) * fix(security): address HH-444 review blockers * fix(security): close remaining HH-444 review blockers --------- Co-authored-by: Rogee <rogee@ipao.vip>
324 lines
11 KiB
Go
324 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
webpush "github.com/SherClockHolmes/webpush-go"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
)
|
|
|
|
// PushDeliveryService sends push notifications to user devices.
|
|
// Reference: Chatwoot web_push_notification_service.rb + P2B M8 spec
|
|
type PushDeliveryService struct {
|
|
pushTokenRepo *repository.PushTokenRepo
|
|
httpClient *http.Client
|
|
vapidPublicKey string
|
|
vapidPrivateKey string
|
|
vapidSubject string
|
|
}
|
|
|
|
// NewPushDeliveryService creates a new PushDelivery service.
|
|
func NewPushDeliveryService(pushTokenRepo *repository.PushTokenRepo, vapidPublicKey, vapidPrivateKey, vapidSubject string) *PushDeliveryService {
|
|
return &PushDeliveryService{
|
|
pushTokenRepo: pushTokenRepo,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
vapidPublicKey: vapidPublicKey,
|
|
vapidPrivateKey: vapidPrivateKey,
|
|
vapidSubject: vapidSubject,
|
|
}
|
|
}
|
|
|
|
// PushPayload represents the payload sent to a push notification service.
|
|
type PushPayload struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Data map[string]interface{} `json:"data,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// SendPushNotification delivers a push notification to all devices for a user.
|
|
// Web Push: RFC 8030 + VAPID (RFC 8291) — encrypts payload and POSTs to subscription endpoint.
|
|
// Mobile: logs intent; FCM/APNs integration requires external config.
|
|
func (s *PushDeliveryService) SendPushNotification(ctx context.Context, userID uint, payload PushPayload) error {
|
|
tokens, err := s.pushTokenRepo.ListByUser(ctx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch push tokens: %w", err)
|
|
}
|
|
if len(tokens) == 0 {
|
|
applogger.L().Debugf("No push tokens for user %d, skipping push delivery", userID)
|
|
return nil
|
|
}
|
|
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal push payload: %w", err)
|
|
}
|
|
|
|
var successCount, failCount int
|
|
for _, t := range tokens {
|
|
switch t.Platform {
|
|
case "web":
|
|
if err := s.deliverWebPush(ctx, t, payloadJSON); err != nil {
|
|
applogger.L().Errorf("Web push delivery failed: user=%d token_id=%d err=%v", userID, t.ID, err)
|
|
failCount++
|
|
} else {
|
|
successCount++
|
|
}
|
|
case "ios", "android":
|
|
// FCM/APNs delivery requires Firebase/Apple config — log as pending integration
|
|
applogger.L().Infof("Mobile push delivery pending (FCM/APNs): user=%d platform=%s token_id=%d",
|
|
userID, t.Platform, t.ID)
|
|
// Future: call FCM HTTP v1 API or APNs HTTP/2 API
|
|
default:
|
|
applogger.L().Warnf("Unknown push platform %s for user %d, skipping", t.Platform, userID)
|
|
}
|
|
}
|
|
applogger.L().Infof("Push delivery summary: user=%d success=%d fail=%d total=%d", userID, successCount, failCount, len(tokens))
|
|
return nil
|
|
}
|
|
|
|
// deliverWebPush encrypts and sends a push notification via the Web Push Protocol (RFC 8030).
|
|
// Requires the push token to have P256DHKey and AuthKey (from browser PushSubscription.keys).
|
|
func (s *PushDeliveryService) deliverWebPush(ctx context.Context, token model.PushToken, payload []byte) error {
|
|
if token.P256DHKey == "" || token.AuthKey == "" {
|
|
return fmt.Errorf("web push token missing encryption keys (p256dh/auth): token_id=%d", token.ID)
|
|
}
|
|
|
|
privateKey, err := parseVAPIDPrivateKey(s.vapidPrivateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("parse VAPID private key: %w", err)
|
|
}
|
|
privateKeyBytes := privateKey.D.FillBytes(make([]byte, 32))
|
|
publicKey, err := privateKey.PublicKey.ECDH()
|
|
if err != nil {
|
|
return fmt.Errorf("convert VAPID public key: %w", err)
|
|
}
|
|
publicKeyBytes := publicKey.Bytes()
|
|
if s.vapidPublicKey != "" {
|
|
configuredPublicKey, err := base64URLDecode(strings.TrimRight(s.vapidPublicKey, "="))
|
|
if err != nil {
|
|
return fmt.Errorf("decode VAPID public key: %w", err)
|
|
}
|
|
if !bytes.Equal(configuredPublicKey, publicKeyBytes) {
|
|
return fmt.Errorf("VAPID public key does not match private key")
|
|
}
|
|
}
|
|
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
|
|
Endpoint: token.Token,
|
|
Keys: webpush.Keys{
|
|
P256dh: token.P256DHKey,
|
|
Auth: token.AuthKey,
|
|
},
|
|
}, &webpush.Options{
|
|
HTTPClient: s.httpClient,
|
|
Subscriber: strings.TrimPrefix(s.vapidSubject, "mailto:"),
|
|
TTL: 86400,
|
|
Urgency: webpush.UrgencyNormal,
|
|
VAPIDPublicKey: base64URLEncode(publicKeyBytes),
|
|
VAPIDPrivateKey: base64URLEncode(privateKeyBytes),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("send push request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
applogger.L().Debugf("Web push delivered: token_id=%d status=%d", token.ID, resp.StatusCode)
|
|
return nil
|
|
}
|
|
|
|
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("push endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// --- Encoding helpers for Web Push (RFC 8291/8292) ---
|
|
|
|
// --- WebhookDeliveryService --- (in same package for convenience)
|
|
|
|
// WebhookDeliveryService sends outgoing webhook events to subscribed URLs.
|
|
// Reference: Chatwoot webhook_service.rb + P2B M8 spec
|
|
type WebhookDeliveryService struct {
|
|
webhookSubRepo *repository.WebhookSubscriptionRepo
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewWebhookDeliveryService creates a new WebhookDelivery service.
|
|
func NewWebhookDeliveryService(webhookSubRepo *repository.WebhookSubscriptionRepo) *WebhookDeliveryService {
|
|
return &WebhookDeliveryService{
|
|
webhookSubRepo: webhookSubRepo,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// DeliverEvent sends an event payload to all matching webhook subscriptions for an account.
|
|
func (s *WebhookDeliveryService) DeliverEvent(ctx context.Context, accountID uint, eventType string, payload map[string]interface{}) error {
|
|
subscriptions, err := s.webhookSubRepo.ListByAccountAndEvent(ctx, accountID, eventType)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch webhook subscriptions: %w", err)
|
|
}
|
|
if len(subscriptions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal webhook payload: %w", err)
|
|
}
|
|
|
|
var deliveryErr error
|
|
for _, sub := range subscriptions {
|
|
if err := s.deliverToSubscription(ctx, sub, eventType, payloadJSON); err != nil {
|
|
applogger.L().Errorf("Webhook delivery failed: subscription=%d url=%s err=%v", sub.ID, sub.URL, err)
|
|
deliveryErr = errors.Join(deliveryErr, fmt.Errorf("deliver subscription %d: %w", sub.ID, err))
|
|
}
|
|
}
|
|
return deliveryErr
|
|
}
|
|
|
|
// deliverToSubscription sends a signed webhook payload to a single subscription URL.
|
|
func (s *WebhookDeliveryService) deliverToSubscription(ctx context.Context, sub model.WebhookSubscription, eventType string, payloadJSON []byte) (resultErr error) {
|
|
// Create delivery record
|
|
delivery := &model.WebhookDelivery{
|
|
SubscriptionID: sub.ID,
|
|
EventType: eventType,
|
|
Payload: payloadJSON,
|
|
Status: "pending",
|
|
Attempts: 0,
|
|
}
|
|
if err := s.webhookSubRepo.CreateDelivery(ctx, delivery); err != nil {
|
|
return fmt.Errorf("create delivery record: %w", err)
|
|
}
|
|
finalizeCtx := context.WithoutCancel(ctx)
|
|
defer func() {
|
|
if err := s.webhookSubRepo.UpdateDelivery(finalizeCtx, delivery); err != nil {
|
|
resultErr = errors.Join(resultErr, fmt.Errorf("update webhook delivery: %w", err))
|
|
}
|
|
}()
|
|
|
|
// Sign the payload with HMAC-SHA256 using the subscription secret
|
|
signature := SignPayload(payloadJSON, sub.Secret)
|
|
|
|
// Build the HTTP request
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.URL, nil)
|
|
if err != nil {
|
|
delivery.Status = "failed"
|
|
return fmt.Errorf("build webhook request: %w", err)
|
|
}
|
|
|
|
// Set headers (Chatwoot webhook pattern)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Webhook-Signature", signature)
|
|
req.Header.Set("X-Webhook-Event", eventType)
|
|
req.Header.Set("X-Webhook-Delivery-ID", fmt.Sprintf("%d", delivery.ID))
|
|
|
|
// Actually send the body
|
|
req.Body = io.NopCloser(bytes.NewReader(payloadJSON))
|
|
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(payloadJSON)), nil }
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
delivery.Attempts++
|
|
|
|
if err != nil {
|
|
delivery.Status = "failed"
|
|
delivery.ResponseCode = 0
|
|
return fmt.Errorf("send webhook: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := resp.Body.Close(); err != nil {
|
|
resultErr = errors.Join(resultErr, fmt.Errorf("close webhook response: %w", err))
|
|
}
|
|
}()
|
|
|
|
delivery.ResponseCode = resp.StatusCode
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
delivery.Status = "success"
|
|
now := time.Now()
|
|
sub.LastDeliveryStatus = "success"
|
|
sub.LastDeliveryAt = &now
|
|
if err := s.webhookSubRepo.Update(ctx, &sub); err != nil {
|
|
resultErr = errors.Join(resultErr, fmt.Errorf("update webhook subscription delivery status: %w", err))
|
|
}
|
|
} else {
|
|
delivery.Status = "failed"
|
|
// Read response body (truncated) for debugging
|
|
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if err != nil {
|
|
resultErr = errors.Join(resultErr, fmt.Errorf("read webhook error response: %w", err))
|
|
} else {
|
|
delivery.ResponseBody = string(bodyBytes)
|
|
}
|
|
}
|
|
return resultErr
|
|
}
|
|
|
|
// SignPayload computes HMAC-SHA256 signature for webhook payload.
|
|
func SignPayload(payload []byte, secret string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// base64URLEncode encodes bytes to base64url without padding (RFC 8291).
|
|
func base64URLEncode(data []byte) string {
|
|
return base64.RawURLEncoding.EncodeToString(data)
|
|
}
|
|
|
|
// base64URLDecode decodes base64url without padding (RFC 8291).
|
|
func base64URLDecode(s string) ([]byte, error) {
|
|
return base64.RawURLEncoding.DecodeString(s)
|
|
}
|
|
|
|
// parseVAPIDPrivateKey parses a base64url-encoded ECDSA P-256 private key.
|
|
func parseVAPIDPrivateKey(keyStr string) (*ecdsa.PrivateKey, error) {
|
|
keyBytes, err := base64URLDecode(keyStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode VAPID key: %w", err)
|
|
}
|
|
// Try PKCS8 first, then SEC1 (raw EC)
|
|
key, err := x509.ParsePKCS8PrivateKey(keyBytes)
|
|
if err != nil {
|
|
// Try SEC1/Raw format
|
|
var ecdsaKey *ecdsa.PrivateKey
|
|
d := new(big.Int).SetBytes(keyBytes)
|
|
if d.Sign() <= 0 || d.Cmp(elliptic.P256().Params().N) >= 0 {
|
|
return nil, fmt.Errorf("VAPID private key is out of range")
|
|
}
|
|
ecdsaKey = &ecdsa.PrivateKey{
|
|
PublicKey: ecdsa.PublicKey{
|
|
Curve: elliptic.P256(),
|
|
X: elliptic.P256().Params().Gx,
|
|
Y: elliptic.P256().Params().Gy,
|
|
},
|
|
D: d,
|
|
}
|
|
// Recalculate public key from D
|
|
ecdsaKey.PublicKey.X, ecdsaKey.PublicKey.Y = elliptic.P256().ScalarBaseMult(d.Bytes())
|
|
return ecdsaKey, nil
|
|
}
|
|
ecdsaKey, ok := key.(*ecdsa.PrivateKey)
|
|
if !ok || ecdsaKey.Curve != elliptic.P256() || ecdsaKey.D.Sign() <= 0 || ecdsaKey.D.Cmp(elliptic.P256().Params().N) >= 0 {
|
|
return nil, fmt.Errorf("VAPID key is not ECDSA P-256")
|
|
}
|
|
return ecdsaKey, nil
|
|
}
|